增加便捷工具
This commit is contained in:
@@ -530,9 +530,32 @@ h3 {
|
||||
:deep(.el-menu) {
|
||||
border-right: none;
|
||||
height: calc(100% - 128px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px 8px;
|
||||
background: transparent;
|
||||
|
||||
// 自定义滚动条样式
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item,
|
||||
.el-sub-menu__title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
row: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
saveLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "save-remark", "detail-action"]);
|
||||
const remarkText = ref("");
|
||||
const remarkDialogVisible = ref(false);
|
||||
const platformDialogVisible = ref(false);
|
||||
const unavailableDialogVisible = ref(false);
|
||||
const unextractDialogVisible = ref(false);
|
||||
const platformForm = reactive({ platform: "local" });
|
||||
|
||||
const TYPE_MAP = {
|
||||
account: { label: "账号密码", type: "success" },
|
||||
account_tk: { label: "账号密码+Token", type: "primary" },
|
||||
tk: { label: "Token", type: "warning" },
|
||||
};
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
local: { label: "本地", type: "info" },
|
||||
xianyu: { label: "闲鱼", type: "warning" },
|
||||
taobao: { label: "淘宝", type: "info" },
|
||||
pinduoduo: { label: "拼多多", type: "danger" },
|
||||
jingdong: { label: "京东", type: "primary" },
|
||||
douyin: { label: "抖音", type: "success" },
|
||||
ziyoushangcheng: { label: "自有商城", type: "warning" },
|
||||
};
|
||||
|
||||
const statusInfo = computed(() => {
|
||||
const status = Number(props.row?.extractStatus || 0);
|
||||
if (status === 2) return { label: "补号", type: "warning" };
|
||||
if (status === 3) return { label: "续杯", type: "primary" };
|
||||
if (props.row?.extracted) return { label: "已提取", type: "success" };
|
||||
return { label: "未提取", type: "info" };
|
||||
});
|
||||
|
||||
const typeInfo = computed(() => {
|
||||
return (
|
||||
TYPE_MAP[props.row?.type] || { label: props.row?.type || "-", type: "info" }
|
||||
);
|
||||
});
|
||||
|
||||
const platformInfo = computed(() => {
|
||||
const key = props.row?.extractedPlatform;
|
||||
if (!key) return { label: "-", type: "info" };
|
||||
return PLATFORM_MAP[key] || { label: key, type: "info" };
|
||||
});
|
||||
|
||||
const isUsedInfo = computed(() => {
|
||||
const raw = props.row?.isUsed;
|
||||
if (raw === null || raw === undefined || raw === "") {
|
||||
return { label: "未探测", type: "info" };
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (n === 1) return { label: "可用", type: "success" };
|
||||
if (n === 0) return { label: "已用完", type: "danger" };
|
||||
return { label: String(raw), type: "info" };
|
||||
});
|
||||
|
||||
const hasAccountPassword = computed(
|
||||
() => !!(props.row?.account || props.row?.password),
|
||||
);
|
||||
const hasToken = computed(() => !!props.row?.token);
|
||||
|
||||
watch(
|
||||
() => props.row,
|
||||
(row) => {
|
||||
remarkText.value = row?.remark || "";
|
||||
platformForm.platform = row?.extractedPlatform || "local";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function closeDialog() {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
function openRemarkDialog() {
|
||||
remarkText.value = props.row?.remark || "";
|
||||
remarkDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function onSaveRemark() {
|
||||
if (!props.row?.id) return;
|
||||
emit("save-remark", { id: props.row.id, remark: remarkText.value || "" });
|
||||
remarkDialogVisible.value = false;
|
||||
}
|
||||
|
||||
function onSetUnavailable() {
|
||||
if (!props.row?.id) return;
|
||||
emit("detail-action", { action: "unavailable", id: props.row.id });
|
||||
unavailableDialogVisible.value = false;
|
||||
}
|
||||
|
||||
function onUpdatePlatform() {
|
||||
if (!props.row?.id) return;
|
||||
emit("detail-action", {
|
||||
action: "platform",
|
||||
id: props.row.id,
|
||||
platform: platformForm.platform,
|
||||
});
|
||||
platformDialogVisible.value = false;
|
||||
}
|
||||
|
||||
function onUnextract() {
|
||||
if (!props.row?.id) return;
|
||||
emit("detail-action", { action: "unextract", id: props.row.id });
|
||||
unextractDialogVisible.value = false;
|
||||
}
|
||||
|
||||
async function copyText(text, successText) {
|
||||
const val = String(text || "").trim();
|
||||
if (!val) {
|
||||
ElMessage.warning("暂无可复制内容");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(val);
|
||||
ElMessage.success(successText || "已复制");
|
||||
} catch {
|
||||
ElMessage.error("复制失败,请检查浏览器权限");
|
||||
}
|
||||
}
|
||||
|
||||
function copyAccountPassword() {
|
||||
const parts = [];
|
||||
if (props.row?.account) parts.push(props.row.account);
|
||||
if (props.row?.password) parts.push(props.row.password);
|
||||
copyText(parts.join("\n"), "已复制账号+密码");
|
||||
}
|
||||
|
||||
function copyToken() {
|
||||
copyText(props.row?.token, "已复制 Token");
|
||||
}
|
||||
|
||||
function copyAll() {
|
||||
const parts = [];
|
||||
if (props.row?.account) parts.push(`账号:${props.row.account}`);
|
||||
if (props.row?.password) parts.push(`密码:${props.row.password}`);
|
||||
if (props.row?.token) parts.push(`Token:${props.row.token}`);
|
||||
copyText(parts.join("\n"), "已复制完整账号信息");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
class="pool-detail-dialog"
|
||||
:model-value="modelValue"
|
||||
width="760px"
|
||||
destroy-on-close
|
||||
:show-close="false"
|
||||
@update:model-value="(v) => emit('update:modelValue', v)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<div class="detail-title">账号详情</div>
|
||||
<div class="detail-subtitle">
|
||||
通过弹窗执行账号状态、平台、备注等维护操作
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button circle plain @click="closeDialog">×</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="row" class="detail-body">
|
||||
<div class="info-grid">
|
||||
<div class="info-card">
|
||||
<div class="info-label">ID</div>
|
||||
<div class="info-value">{{ row?.id || "-" }}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">账号类型</div>
|
||||
<div class="info-value">
|
||||
<el-tag :type="typeInfo.type" round>{{ typeInfo.label }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">提取状态</div>
|
||||
<div class="info-value">
|
||||
<el-tag :type="statusInfo.type" effect="dark" round>
|
||||
{{ statusInfo.label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">提取平台</div>
|
||||
<div class="info-value">
|
||||
<el-tag
|
||||
v-if="row.extractedPlatform"
|
||||
:type="platformInfo.type"
|
||||
size="small"
|
||||
>
|
||||
{{ platformInfo.label }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">提取时间</div>
|
||||
<div class="info-value">{{ row.extractedAt || "-" }}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">可用检测</div>
|
||||
<div class="info-value">
|
||||
<el-tag :type="isUsedInfo.type" round>
|
||||
{{ isUsedInfo.label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">账号</div>
|
||||
<div class="info-value">{{ row.account || "-" }}</div>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<div class="info-label">密码</div>
|
||||
<div class="info-value">{{ row.password || "-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="section-title">Token</div>
|
||||
<div class="section-subtitle">
|
||||
长 Token 已做自动换行,便于检查与复制
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!hasToken"
|
||||
@click="copyToken"
|
||||
>
|
||||
复制 Token
|
||||
</el-button>
|
||||
</div>
|
||||
<pre class="token-box">{{ row.token || "暂无 Token" }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="section-title">快捷功能</div>
|
||||
<div class="section-subtitle">按使用场景复制账号信息</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copy-actions">
|
||||
<el-button
|
||||
:disabled="!hasAccountPassword"
|
||||
@click="copyAccountPassword"
|
||||
>
|
||||
复制账号+密码
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!hasToken"
|
||||
@click="copyToken"
|
||||
>
|
||||
复制 Token
|
||||
</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
plain
|
||||
:disabled="!hasAccountPassword && !hasToken"
|
||||
@click="copyAll"
|
||||
>
|
||||
复制全部
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="section-title">维护操作</div>
|
||||
<div class="section-subtitle">
|
||||
点击按钮后打开确认/编辑弹窗,再执行对应操作
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copy-actions">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
@click="unavailableDialogVisible = true"
|
||||
>
|
||||
改不可用
|
||||
</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
plain
|
||||
@click="platformDialogVisible = true"
|
||||
>
|
||||
改平台
|
||||
</el-button>
|
||||
<el-button type="info" plain @click="unextractDialogVisible = true">
|
||||
反提取
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openRemarkDialog">
|
||||
改备注
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="section-title">备注</div>
|
||||
<div class="section-subtitle">备注改为弹窗编辑,当前仅展示</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="remark-display">{{ row.remark || "暂无备注" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="unavailableDialogVisible"
|
||||
title="改不可用"
|
||||
width="420px"
|
||||
append-to-body
|
||||
>
|
||||
<el-alert type="warning" :closable="false">
|
||||
确认将当前账号标记为不可用/已用完?
|
||||
</el-alert>
|
||||
<template #footer>
|
||||
<el-button @click="unavailableDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="saveLoading" @click="onSetUnavailable">
|
||||
确认改不可用
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="platformDialogVisible"
|
||||
title="改平台"
|
||||
width="420px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form label-width="84px">
|
||||
<el-form-item label="提取平台">
|
||||
<el-select v-model="platformForm.platform" style="width: 100%">
|
||||
<el-option
|
||||
v-for="(v, k) in PLATFORM_MAP"
|
||||
:key="k"
|
||||
:label="v.label"
|
||||
:value="k"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="platformDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saveLoading" @click="onUpdatePlatform">
|
||||
确认修改
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="unextractDialogVisible"
|
||||
title="反提取"
|
||||
width="420px"
|
||||
append-to-body
|
||||
>
|
||||
<el-alert type="warning" :closable="false">
|
||||
反提取会把账号恢复为未提取,并清空提取时间与提取平台。
|
||||
</el-alert>
|
||||
<template #footer>
|
||||
<el-button @click="unextractDialogVisible = false">取消</el-button>
|
||||
<el-button type="warning" :loading="saveLoading" @click="onUnextract">
|
||||
确认反提取
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="remarkDialogVisible"
|
||||
title="改备注"
|
||||
width="520px"
|
||||
append-to-body
|
||||
>
|
||||
<el-input
|
||||
v-model="remarkText"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
resize="none"
|
||||
placeholder="请输入备注"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="remarkDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saveLoading" @click="onSaveRemark">
|
||||
保存备注
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.pool-detail-dialog) {
|
||||
max-width: calc(100vw - 28px);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.pool-detail-dialog .el-dialog__header) {
|
||||
padding: 18px 22px;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid #eef0f5;
|
||||
}
|
||||
|
||||
:deep(.pool-detail-dialog .el-dialog__body) {
|
||||
padding: 18px 22px 22px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
:deep(.el-tag) {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.detail-header,
|
||||
.header-actions,
|
||||
.section-head,
|
||||
.copy-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.detail-subtitle,
|
||||
.section-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-actions .el-button {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.section-card,
|
||||
.info-card {
|
||||
background: #fff;
|
||||
border: 1px solid #edf0f6;
|
||||
box-shadow: 0 10px 28px rgba(31, 41, 55, 0.06);
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.token-box {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
border-radius: 12px;
|
||||
background: #111827;
|
||||
color: #d1e7ff;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.copy-actions .el-button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.remark-display {
|
||||
padding: 12px;
|
||||
min-height: 42px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #303133;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.pool-detail-dialog) {
|
||||
width: calc(100vw - 24px) !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
:deep(.pool-detail-dialog .el-dialog__header) {
|
||||
padding: 14px 14px;
|
||||
}
|
||||
|
||||
:deep(.pool-detail-dialog .el-dialog__body) {
|
||||
padding: 12px;
|
||||
max-height: 76vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-header,
|
||||
.section-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.copy-actions .el-button,
|
||||
.section-head .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'single', // single | batch
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit']);
|
||||
|
||||
const form = reactive({
|
||||
type: 'account', // account | tk | account_tk
|
||||
account: '',
|
||||
password: '',
|
||||
token: '',
|
||||
batchText: '',
|
||||
remark: '',
|
||||
});
|
||||
|
||||
const isBatch = computed(() => props.mode === 'batch');
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
return isBatch.value ? '批量添加账号' : '添加账号';
|
||||
});
|
||||
|
||||
const formatExample = computed(() => {
|
||||
if (form.type === 'account') {
|
||||
return 'account,password';
|
||||
}
|
||||
if (form.type === 'account_tk') {
|
||||
return 'account,password,token';
|
||||
}
|
||||
return 'token';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (!visible) return;
|
||||
resetForm();
|
||||
}
|
||||
);
|
||||
|
||||
function closeDialog() {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.type = 'account';
|
||||
form.account = '';
|
||||
form.password = '';
|
||||
form.token = '';
|
||||
form.batchText = '';
|
||||
form.remark = '';
|
||||
}
|
||||
|
||||
function parseBatchRows() {
|
||||
const rows = form.batchText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const parsed = [];
|
||||
const errors = [];
|
||||
|
||||
rows.forEach((line, index) => {
|
||||
if (form.type === 'account') {
|
||||
const [account, password] = line.split(',').map((x) => (x || '').trim());
|
||||
if (!account || !password) {
|
||||
errors.push(`第 ${index + 1} 行格式错误,应为 account,password`);
|
||||
return;
|
||||
}
|
||||
parsed.push({
|
||||
type: 'account',
|
||||
account,
|
||||
password,
|
||||
token: '',
|
||||
remark: form.remark,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.type === 'account_tk') {
|
||||
const [account, password, token] = line
|
||||
.split(',')
|
||||
.map((x) => (x || '').trim());
|
||||
if (!account || !token) {
|
||||
errors.push(`第 ${index + 1} 行格式错误,应为 account,password,token(password 可为空)`);
|
||||
return;
|
||||
}
|
||||
parsed.push({
|
||||
type: 'account_tk',
|
||||
account,
|
||||
password,
|
||||
token,
|
||||
remark: form.remark,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
parsed.push({
|
||||
type: 'tk',
|
||||
account: '',
|
||||
password: '',
|
||||
token: line,
|
||||
remark: form.remark,
|
||||
});
|
||||
});
|
||||
|
||||
return { parsed, errors };
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!isBatch.value) {
|
||||
if (form.type === 'account') {
|
||||
if (!form.account || !form.password) {
|
||||
return;
|
||||
}
|
||||
emit('submit', {
|
||||
mode: 'single',
|
||||
rows: [
|
||||
{
|
||||
type: 'account',
|
||||
account: form.account.trim(),
|
||||
password: form.password.trim(),
|
||||
token: '',
|
||||
remark: form.remark.trim(),
|
||||
},
|
||||
],
|
||||
});
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.type === 'account_tk') {
|
||||
if (!form.account || !form.token) {
|
||||
ElMessage.warning('请输入账号和 Token,密码可为空');
|
||||
return;
|
||||
}
|
||||
emit('submit', {
|
||||
mode: 'single',
|
||||
rows: [
|
||||
{
|
||||
type: 'account_tk',
|
||||
account: form.account.trim(),
|
||||
password: form.password.trim(),
|
||||
token: form.token.trim(),
|
||||
remark: form.remark.trim(),
|
||||
},
|
||||
],
|
||||
});
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.token) return;
|
||||
emit('submit', {
|
||||
mode: 'single',
|
||||
rows: [
|
||||
{
|
||||
type: 'tk',
|
||||
account: '',
|
||||
password: '',
|
||||
token: form.token.trim(),
|
||||
remark: form.remark.trim(),
|
||||
},
|
||||
],
|
||||
});
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const { parsed, errors } = parseBatchRows();
|
||||
if (errors.length || parsed.length === 0) {
|
||||
ElMessage.warning(errors[0] || '请填写批量内容');
|
||||
return;
|
||||
}
|
||||
emit('submit', {
|
||||
mode: 'batch',
|
||||
rows: parsed,
|
||||
});
|
||||
closeDialog();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="dialogTitle"
|
||||
width="640px"
|
||||
@close="closeDialog"
|
||||
@update:model-value="(v) => emit('update:modelValue', v)"
|
||||
>
|
||||
<el-form label-width="96px">
|
||||
<el-form-item label="账号类型">
|
||||
<el-radio-group v-model="form.type">
|
||||
<el-radio value="account">账号密码</el-radio>
|
||||
<el-radio value="account_tk">账号密码+Token</el-radio>
|
||||
<el-radio value="tk">Token</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="!isBatch">
|
||||
<template v-if="form.type === 'account' || form.type === 'account_tk'">
|
||||
<el-form-item label="账号">
|
||||
<el-input v-model="form.account" placeholder="请输入账号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" placeholder="请输入密码" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.type === 'account_tk'" label="Token">
|
||||
<el-input
|
||||
v-model="form.token"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入 token"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item v-else label="Token">
|
||||
<el-input
|
||||
v-model="form.token"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入 token"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item v-else label="批量内容">
|
||||
<el-input
|
||||
v-model="form.batchText"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
:placeholder="`每行一条,格式:${formatExample}`"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" placeholder="可选备注" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="closeDialog">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'account' },
|
||||
platform: { type: String, default: 'local' },
|
||||
remark: { type: String, default: '' },
|
||||
replenish: { type: Boolean, default: false },
|
||||
platformMap: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'update:platform',
|
||||
'update:remark',
|
||||
'update:replenish',
|
||||
'confirm',
|
||||
]);
|
||||
|
||||
function typeText(type) {
|
||||
if (type === 'account') return '账号密码';
|
||||
if (type === 'account_tk') return '账号密码+Token';
|
||||
return 'Token';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
class="pool-extract-dialog"
|
||||
:model-value="modelValue"
|
||||
title="提取账号"
|
||||
width="90%"
|
||||
@update:model-value="(v) => emit('update:modelValue', v)"
|
||||
>
|
||||
<el-form label-width="84px">
|
||||
<el-form-item label="提取类型">
|
||||
<el-input :model-value="typeText(type)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否补号">
|
||||
<el-switch
|
||||
:model-value="replenish"
|
||||
active-text="是"
|
||||
inactive-text="否"
|
||||
@update:model-value="(v) => emit('update:replenish', v)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="提取平台">
|
||||
<el-select
|
||||
:model-value="platform"
|
||||
style="width: 100%"
|
||||
@update:model-value="(v) => emit('update:platform', v)"
|
||||
>
|
||||
<el-option v-for="(v, k) in platformMap" :key="k" :value="k" :label="v.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
:model-value="remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="提取备注(可选)"
|
||||
@update:model-value="(v) => emit('update:remark', v)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="emit('confirm')">确认提取</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.pool-extract-dialog) {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.pool-extract-dialog) {
|
||||
width: calc(100vw - 24px) !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
:deep(.pool-extract-dialog .el-dialog__body) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
:deep(.pool-extract-dialog .el-form-item__label) {
|
||||
width: 74px !important;
|
||||
}
|
||||
|
||||
:deep(.pool-extract-dialog .el-dialog__footer .el-button) {
|
||||
width: calc(50% - 6px);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.pool-extract-dialog .el-dialog__footer) {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'tk' },
|
||||
platform: { type: String, default: 'local' },
|
||||
remark: { type: String, default: '' },
|
||||
platformMap: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:type', 'update:platform', 'update:remark', 'confirm']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="补号"
|
||||
width="420px"
|
||||
@update:model-value="(v) => emit('update:modelValue', v)"
|
||||
>
|
||||
<el-form label-width="84px">
|
||||
<el-form-item label="账号类型">
|
||||
<el-select :model-value="type" style="width: 100%" @update:model-value="(v) => emit('update:type', v)">
|
||||
<el-option label="Token" value="tk" />
|
||||
<el-option label="账号密码" value="account" />
|
||||
<el-option label="账号密码+Token" value="account_tk" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="提取平台">
|
||||
<el-select :model-value="platform" style="width: 100%" @update:model-value="(v) => emit('update:platform', v)">
|
||||
<el-option v-for="(v, k) in platformMap" :key="k" :value="k" :label="v.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
:model-value="remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="提取备注(可选)"
|
||||
@update:model-value="(v) => emit('update:remark', v)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="warning" :loading="loading" @click="emit('confirm')">确认补号</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,241 +0,0 @@
|
||||
<template>
|
||||
<div class="content-stats-container">
|
||||
<el-row :gutter="20" class="stat-cards">
|
||||
<el-col :span="6" v-for="card in topCards" :key="card.label">
|
||||
<el-card shadow="never" class="stat-card">
|
||||
<div class="label">{{ card.label }}</div>
|
||||
<div class="count">{{ card.count.toLocaleString() }}</div>
|
||||
<div class="sub-info" v-if="card.yesterday !== undefined">
|
||||
昨日 {{ card.yesterday || 0 }}
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="main-charts">
|
||||
<el-col :span="16">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>内容发布活跃度</span>
|
||||
<el-radio-group v-model="timeRange" size="small">
|
||||
<el-radio-button label="week">近一周</el-radio-button>
|
||||
<el-radio-button label="month">近一月</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="barChartRef" class="chart-box"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" header="内容分类占比">
|
||||
<div ref="categoryChartRef" class="chart-box"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" class="bottom-row">
|
||||
<el-col :span="24">
|
||||
<el-card shadow="hover" header="热门内容TOP 5">
|
||||
<el-table :data="hotContent" style="width: 100%" stripe>
|
||||
<el-table-column type="index" label="排名" width="80" />
|
||||
<el-table-column prop="title" label="标题" show-overflow-tooltip />
|
||||
<el-table-column prop="cate" label="分类" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.cate || '未分类' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="views" label="阅读量" sortable />
|
||||
<el-table-column prop="likes" label="点赞数" width="120" />
|
||||
<el-table-column prop="publish_date" label="发布时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.publish_date ? row.publish_date.slice(0, 16).replace('T', ' ') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, shallowRef } from "vue";
|
||||
import * as echarts from "echarts";
|
||||
|
||||
import { getContentStats } from "@/api/analytics";
|
||||
|
||||
interface TopCard {
|
||||
label: string;
|
||||
count: number;
|
||||
yesterday?: number;
|
||||
}
|
||||
|
||||
interface HotArticle {
|
||||
id: number;
|
||||
title: string;
|
||||
cate: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
publish_date: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
const timeRange = ref("week");
|
||||
const barChartRef = ref<HTMLElement | null>(null);
|
||||
const categoryChartRef = ref<HTMLElement | null>(null);
|
||||
const barChart = shallowRef<echarts.ECharts | null>(null);
|
||||
const categoryChart = shallowRef<echarts.ECharts | null>(null);
|
||||
|
||||
const topCards = ref<TopCard[]>([
|
||||
{ label: "总发布量", count: 0 },
|
||||
{ label: "本月新增", count: 0 },
|
||||
{ label: "总点赞量", count: 0 },
|
||||
{ label: "总访问量", count: 0 },
|
||||
]);
|
||||
|
||||
const hotContent = ref<HotArticle[]>([]);
|
||||
|
||||
async function fetchContentStats() {
|
||||
const res = await getContentStats();
|
||||
if (res.code === 200 && res.data) {
|
||||
const { total_articles, month_articles, total_likes, total_views, hot_articles } = res.data;
|
||||
|
||||
topCards.value = [
|
||||
{ label: "总发布量", count: total_articles || 0 },
|
||||
{ label: "本月新增", count: month_articles || 0 },
|
||||
{ label: "总点赞量", count: total_likes || 0 },
|
||||
{ label: "总访问量", count: total_views || 0 },
|
||||
];
|
||||
|
||||
hotContent.value = hot_articles || [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- 图表初始化 ---
|
||||
const initCharts = () => {
|
||||
// 柱状图:发布趋势
|
||||
if (barChartRef.value) {
|
||||
barChart.value = echarts.init(barChartRef.value);
|
||||
barChart.value.setOption({
|
||||
tooltip: { trigger: "axis" },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
axisLine: { lineStyle: { color: "#DCDFE6" } },
|
||||
},
|
||||
yAxis: { type: "value" },
|
||||
series: [
|
||||
{
|
||||
name: "发布篇数",
|
||||
data: [45, 52, 38, 65, 48, 23, 31],
|
||||
type: "bar",
|
||||
barWidth: "40%",
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: "#83bff6" },
|
||||
{ offset: 0.5, color: "#188df0" },
|
||||
{ offset: 1, color: "#188df0" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 环形图:分类占比
|
||||
if (categoryChartRef.value) {
|
||||
categoryChart.value = echarts.init(categoryChartRef.value);
|
||||
categoryChart.value.setOption({
|
||||
tooltip: { trigger: "item" },
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["50%", "70%"],
|
||||
data: [
|
||||
{ value: 40, name: "技术文章" },
|
||||
{ value: 30, name: "行业资讯" },
|
||||
{ value: 20, name: "视频教程" },
|
||||
{ value: 10, name: "资源分享" },
|
||||
],
|
||||
emphasis: {
|
||||
label: { show: true, fontSize: "16", fontWeight: "bold" },
|
||||
},
|
||||
label: { show: false, position: "center" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
barChart.value?.resize();
|
||||
categoryChart.value?.resize();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchContentStats();
|
||||
initCharts();
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.content-stats-container {
|
||||
min-height: 100vh;
|
||||
|
||||
.stat-cards {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.stat-card {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
.label {
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.count {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sub-info {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
.plus {
|
||||
color: #52c41a;
|
||||
}
|
||||
.minus {
|
||||
color: #f5222d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-charts {
|
||||
margin-bottom: 24px;
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.chart-box {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
:deep(.el-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,493 +0,0 @@
|
||||
<template>
|
||||
<div class="category-manager">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<h3>文章分类</h3>
|
||||
<p>共 {{ totalCount }} 个分类</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-button type="primary" :icon="Plus" @click="handleAdd">
|
||||
新增分类
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="category-list" v-loading="loading">
|
||||
<div v-if="filteredCategories.length === 0" class="empty-state">
|
||||
<el-empty description="暂无文章分类" />
|
||||
</div>
|
||||
|
||||
<div v-else class="category-tree">
|
||||
<category-node
|
||||
v-for="category in filteredCategories"
|
||||
:key="category.id"
|
||||
:item="category"
|
||||
:level="0"
|
||||
@edit="handleEdit"
|
||||
@add-child="handleAddChild"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<edit-cate
|
||||
v-model="dialogVisible"
|
||||
:category="currentEdit"
|
||||
@saved="fetchCategories"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, nextTick } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Delete,
|
||||
Check,
|
||||
Close,
|
||||
Search,
|
||||
Refresh,
|
||||
Document,
|
||||
Picture,
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { allCategories, deleteCategory } from "@/api/article";
|
||||
import EditCate from "@/views/apps/cms/articles/components/edit-cate.vue";
|
||||
import CategoryNode from "./components/CategoryNode.vue";
|
||||
|
||||
// 颜色预设
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const currentEdit = ref(null);
|
||||
const searchText = ref("");
|
||||
const categories = ref([]);
|
||||
|
||||
// 新增分类
|
||||
const addCategory = ref(null);
|
||||
const handleAdd = () => {
|
||||
addCategory.value = { parentId: 0 };
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const totalCount = computed(() => categories.value.length);
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
if (!searchText.value) {
|
||||
return categories.value;
|
||||
}
|
||||
|
||||
const search = searchText.value.toLowerCase();
|
||||
return categories.value.filter(
|
||||
(category) =>
|
||||
category.label.toLowerCase().includes(search) ||
|
||||
(category.remark && category.remark.toLowerCase().includes(search)),
|
||||
);
|
||||
});
|
||||
|
||||
// 工具函数
|
||||
function buildTree(data) {
|
||||
// 1. 标准化数据字段
|
||||
const transformed = data.map((item) => ({
|
||||
...item,
|
||||
label: item.name ?? item.label ?? "",
|
||||
remark: item.desc ?? item.remark ?? "",
|
||||
parentId: item.parentId ?? item.cid ?? 0,
|
||||
children: [],
|
||||
expanded: true, // 默认展开所有级
|
||||
}));
|
||||
|
||||
const map = new Map();
|
||||
transformed.forEach((item) => map.set(item.id, item));
|
||||
|
||||
const roots = [];
|
||||
transformed.forEach((item) => {
|
||||
const parent = map.get(item.parentId);
|
||||
if (parent) {
|
||||
parent.children.push(item);
|
||||
} else {
|
||||
// 如果没有父节点,或者是顶级节点 (parentId 为 0)
|
||||
roots.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
function handleCreate() {
|
||||
currentEdit.value = null;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(category) {
|
||||
currentEdit.value = category;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleAddChild(parent) {
|
||||
currentEdit.value = { parentId: parent.id };
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
searchText.value = "";
|
||||
fetchCategories();
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
await fetchCategories();
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchText.value = "";
|
||||
fetchCategories();
|
||||
}
|
||||
|
||||
function toggleExpand(category) {
|
||||
category.expanded = !category.expanded;
|
||||
}
|
||||
|
||||
function handleDelete(category) {
|
||||
ElMessageBox.confirm(`确定要删除分类\"${category.label}\"吗?`, "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deleteCategory(category.id)
|
||||
.then(() => {
|
||||
ElMessage.success("删除成功");
|
||||
fetchCategories();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("删除分类失败:", error);
|
||||
ElMessage.error("删除失败");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// API调用
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await allCategories({ keyword: searchText.value });
|
||||
if (response.code === 200) {
|
||||
const categoryList = response.data || [];
|
||||
// 这里会递归生成三级、四级等
|
||||
categories.value = buildTree(categoryList);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.category-manager {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
background-color: var(--el-bg-color-page);
|
||||
|
||||
// 页面头部样式
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
background: var(--el-bg-color);
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.page-title {
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
.el-button {
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
.el-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索栏样式
|
||||
.search-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
background: var(--el-bg-color);
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.el-input {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.search-stats {
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 分类列表容器
|
||||
.category-list {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
|
||||
.empty-state {
|
||||
padding: 80px 40px;
|
||||
.empty-icon {
|
||||
color: #c9cdd4;
|
||||
}
|
||||
.el-button {
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 递归树结构核心样式
|
||||
.category-tree {
|
||||
.category-item {
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
&.disabled {
|
||||
.category-main {
|
||||
opacity: 0.6;
|
||||
background-color: var(--el-fill-color-lightest);
|
||||
}
|
||||
}
|
||||
|
||||
// 层级背景区分 (可选:让子级背景稍微深一点点)
|
||||
&.child-item {
|
||||
.category-main {
|
||||
background-color: rgba(245, 247, 250, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.category-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 24px; // 左右 padding 保持,左侧缩进通过内联 style 控制
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light) !important;
|
||||
}
|
||||
|
||||
// 展开收起按钮占位
|
||||
.expand-btn,
|
||||
.expand-spacer {
|
||||
width: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
.expand-button {
|
||||
padding: 4px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分类具体内容
|
||||
.category-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
|
||||
.category-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.category-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.status-tag {
|
||||
font-size: 10px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
.category-desc {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮组
|
||||
.category-actions {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
opacity: 0.4; // 默认低透明度,鼠标悬浮时高亮
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.el-button-group .el-button {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin-left: 4px;
|
||||
&.danger:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 鼠标悬停时显示按钮
|
||||
&:hover .category-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 子分类容器样式
|
||||
.category-children {
|
||||
margin-left: 0;
|
||||
|
||||
.category-node-wrapper {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Element Plus 对话框深度样式美化
|
||||
:deep(.el-dialog) {
|
||||
border-radius: 12px;
|
||||
.el-dialog__header {
|
||||
padding: 20px 24px;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.el-dialog__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.el-dialog__body {
|
||||
padding: 24px;
|
||||
}
|
||||
.el-dialog__footer {
|
||||
padding: 16px 24px 24px;
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.el-input__inner,
|
||||
.el-textarea__inner {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.category-manager {
|
||||
padding: 12px;
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
.page-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.search-bar {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.category-list .category-tree .category-item .category-main {
|
||||
padding: 12px !important; // 移动端取消大缩进,改用其他视觉暗示
|
||||
.category-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.category-desc {
|
||||
display: none;
|
||||
} // 隐藏描述节省空间
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,269 +0,0 @@
|
||||
<template>
|
||||
<div class="category-node-wrapper">
|
||||
<div
|
||||
class="category-item"
|
||||
:class="{ 'child-item': level > 0, disabled: !item.status }"
|
||||
>
|
||||
<div
|
||||
class="category-main"
|
||||
:style="{ paddingLeft: (level * 24 + 20) + 'px' }"
|
||||
@click="handleRowClick"
|
||||
>
|
||||
<div class="expand-btn" v-if="item.children && item.children.length > 0">
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="toggleExpand"
|
||||
class="expand-button"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="item.expanded ? 'ArrowDown' : 'ArrowRight'" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="expand-spacer" v-else></div>
|
||||
|
||||
<div class="category-info">
|
||||
<div class="color-dot" :style="{ backgroundColor: item.color }"></div>
|
||||
<div class="category-icon">
|
||||
<el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
|
||||
<el-icon v-else class="default-icon"><Document /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="category-text">
|
||||
<div class="category-title">
|
||||
<span class="name">{{ item.label }}</span>
|
||||
<el-tag
|
||||
:type="item.status ? 'success' : 'danger'"
|
||||
size="small"
|
||||
class="status-tag"
|
||||
>
|
||||
{{ item.status ? "启用" : "禁用" }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="category-desc" v-if="item.remark">{{ item.remark }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="category-actions">
|
||||
<el-button-group size="small">
|
||||
<el-button type="text" title="编辑" @click.stop="$emit('edit', item)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="text" title="添加子分类" @click.stop="$emit('add-child', item)">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="text" class="danger" title="删除" @click.stop="$emit('delete', item)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="item.children && item.children.length > 0 && item.expanded"
|
||||
class="category-children"
|
||||
>
|
||||
<category-node
|
||||
v-for="child in item.children"
|
||||
:key="child.id"
|
||||
:item="child"
|
||||
:level="level + 1"
|
||||
@edit="$emit('edit', $event)"
|
||||
@add-child="$emit('add-child', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArrowDown, ArrowRight, Edit, Plus, Delete, Document } from "@element-plus/icons-vue";
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
level: { type: Number, default: 0 }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'add-child', 'delete']);
|
||||
|
||||
const toggleExpand = () => {
|
||||
props.item.expanded = !props.item.expanded;
|
||||
};
|
||||
|
||||
const handleRowClick = () => {
|
||||
if (props.item.children && props.item.children.length > 0) {
|
||||
toggleExpand();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.category-node-wrapper {
|
||||
.category-item {
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
.category-main {
|
||||
opacity: 0.6;
|
||||
background-color: var(--el-fill-color-lightest);
|
||||
}
|
||||
}
|
||||
|
||||
&.child-item {
|
||||
.category-main {
|
||||
background-color: rgba(245, 247, 250, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.category-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 24px;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light) !important;
|
||||
}
|
||||
|
||||
.expand-btn,
|
||||
.expand-spacer {
|
||||
width: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
.expand-button {
|
||||
padding: 4px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
|
||||
.category-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.category-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
font-size: 10px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-desc {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-actions {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16px;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.el-button-group .el-button {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin-left: 4px;
|
||||
|
||||
&.danger:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .category-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-children {
|
||||
.category-node-wrapper {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.category-node-wrapper {
|
||||
.category-item {
|
||||
.category-main {
|
||||
padding: 12px !important;
|
||||
|
||||
.category-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.category-desc {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,324 +0,0 @@
|
||||
<template>
|
||||
<el-dialog v-model="visibleInternal" :title="dialogTitle" width="600px" :close-on-click-modal="false"
|
||||
@close="closeDialog">
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||
<el-form-item label="分类名称" prop="label">
|
||||
<el-input v-model="formData.label" placeholder="请输入分类名称" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="父级分类" prop="parentId">
|
||||
<el-tree-select v-model="formData.parentId" :data="treeData"
|
||||
:props="{ label: 'label', value: 'id', children: 'children' }" placeholder="选择父级分类(可选)" clearable
|
||||
filterable check-strictly style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" :min="0" :max="999" controls-position="right"
|
||||
style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="默认图片" prop="image">
|
||||
<div class="uploads">
|
||||
<el-upload v-model:file-list="fileList" :auto-upload="false" :before-upload="beforeImgUpload" list-type="picture-card" :limit="1" :on-change="handleUploadChange">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
|
||||
<template #file="{ file }">
|
||||
<div>
|
||||
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
|
||||
<span class="el-upload-list__item-actions">
|
||||
<span class="el-upload-list__item-preview" @click="handlePictureCardPreview(file)">
|
||||
<el-icon>
|
||||
<ZoomIn />
|
||||
</el-icon>
|
||||
</span>
|
||||
<span class="el-upload-list__item-delete" @click="handleRemove(file)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
<el-dialog v-model="dialogVisible">
|
||||
<img w-full :src="dialogImageUrl" alt="Preview Image" />
|
||||
</el-dialog>
|
||||
|
||||
<div class="upload-tip">
|
||||
<span>建议尺寸:250px × 140px</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="分类描述" prop="remark">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="4" placeholder="请输入分类描述..." maxlength="200"
|
||||
show-word-limit />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio :label="1">启用</el-radio>
|
||||
<el-radio :label="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="closeDialog">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
{{ isEdit ? '更新' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { Plus, ZoomIn, Delete } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElUpload } from 'element-plus'
|
||||
import { createCategory, editCategory, listCategories } from '@/api/article'
|
||||
import { uploadFile } from '@/api/file.js'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
category: { type: Object as () => any | null, default: null }, // 传入的分类对象,null 表示新增
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
// 可见性同步
|
||||
const visibleInternal = ref(props.modelValue)
|
||||
watch(() => props.modelValue, v => (visibleInternal.value = v))
|
||||
watch(visibleInternal, v => {
|
||||
emit('update:modelValue', v)
|
||||
if (v) loadTreeData()
|
||||
})
|
||||
|
||||
// 数据加载:父级分类树
|
||||
const treeData = ref<any[]>([])
|
||||
watch(treeData, () => {
|
||||
// 触发tree-select重新渲染已选 label
|
||||
formData.parentId = formData.parentId ?? 0
|
||||
})
|
||||
|
||||
async function loadTreeData() {
|
||||
const res = await listCategories({ page: 1, pageSize: 1000 })
|
||||
if (res.code === 200) {
|
||||
const list = res.data?.records || res.data || []
|
||||
treeData.value = buildTree(list)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建树形
|
||||
function buildTree(data: any[]) {
|
||||
const map = new Map()
|
||||
const roots: any[] = []
|
||||
data.forEach((item) => {
|
||||
map.set(item.id, { ...item, label: item.name ?? item.label, children: [] })
|
||||
})
|
||||
map.forEach((item: any) => {
|
||||
if (item.cid && map.has(item.cid)) {
|
||||
map.get(item.cid).children.push(item)
|
||||
} else {
|
||||
roots.push(item)
|
||||
}
|
||||
})
|
||||
return [{ id: 0, label: '顶级', children: roots }]
|
||||
}
|
||||
|
||||
// 表单
|
||||
const formRef = ref()
|
||||
const formData = reactive({
|
||||
id: null as number | null,
|
||||
label: '',
|
||||
image: '',
|
||||
remark: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
parentId: 0 as number,
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
if (formRef.value) formRef.value.clearValidate()
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
label: '',
|
||||
image: '',
|
||||
remark: '',
|
||||
sort: 0,
|
||||
status: 1,
|
||||
parentId: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 当传入 category 变化时同步
|
||||
watch(
|
||||
() => props.category,
|
||||
(val) => {
|
||||
if (val) {
|
||||
Object.assign(formData, {
|
||||
id: val.id,
|
||||
label: val.label,
|
||||
image: val.image,
|
||||
remark: val.remark,
|
||||
sort: val.sort,
|
||||
status: val.status,
|
||||
parentId: val.parentId ?? val.cid ?? 0,
|
||||
})
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 上传相关
|
||||
const fileList = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogImageUrl = ref('')
|
||||
|
||||
function beforeImgUpload(file: File) {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt10M = file.size / 1024 / 1024 < 10
|
||||
if (!isImage) ElMessage.error('仅支持图片格式')
|
||||
if (!isLt10M) ElMessage.error('图片大小不能超过10MB')
|
||||
return isImage && isLt10M
|
||||
}
|
||||
|
||||
function handleRemove(file: any) {
|
||||
fileList.value = []
|
||||
formData.image = ''
|
||||
}
|
||||
|
||||
async function handleUploadChange(file: any) {
|
||||
if (file.raw) {
|
||||
const isImage = file.raw.type.startsWith('image/')
|
||||
const isLt10M = file.raw.size / 1024 / 1024 < 10
|
||||
if (!isImage || !isLt10M) {
|
||||
fileList.value = []
|
||||
ElMessage.error(isImage ? '图片大小不能超过10MB' : '仅支持图片格式')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePictureCardPreview(file: any) {
|
||||
dialogImageUrl.value = file.url
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 校验
|
||||
const formRules = {
|
||||
label: [
|
||||
{ required: true, message: '请输入分类名称', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '分类名称长度在 2 到 50 个字符', trigger: 'blur' },
|
||||
],
|
||||
remark: [{ max: 200, message: '描述不能超过200个字符', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const isEdit = computed(() => !!formData.id)
|
||||
// 预览对话框
|
||||
|
||||
const dialogTitle = computed(() => (isEdit.value ? '编辑分类' : '新增分类'))
|
||||
const submitLoading = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
submitLoading.value = true
|
||||
|
||||
let imageUrl = formData.image
|
||||
|
||||
if (fileList.value.length > 0 && fileList.value[0].raw) {
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', fileList.value[0].raw)
|
||||
uploadFormData.append('cate', 'category')
|
||||
|
||||
const uploadRes = await uploadFile(uploadFormData)
|
||||
if (uploadRes?.data?.url) {
|
||||
imageUrl = uploadRes.data.url
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formData.label,
|
||||
image: imageUrl,
|
||||
desc: formData.remark,
|
||||
sort: formData.sort,
|
||||
status: formData.status,
|
||||
cid: formData.parentId,
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await editCategory(formData.id, payload)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createCategory(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
emit('saved')
|
||||
closeDialog()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visibleInternal.value = false
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 如果需要额外样式,可在此处编写 */
|
||||
.avatar-uploader {
|
||||
.el-upload {
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: block;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.avatar-uploader-icon {
|
||||
font-size: 32px;
|
||||
color: #8c939d;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.uploads{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.upload-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@@ -1,686 +0,0 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:title="isEdit ? '编辑文章' : '新增文章'"
|
||||
size="60%"
|
||||
:before-close="handleBeforeClose"
|
||||
>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入文章标题" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="分类" prop="cate">
|
||||
<el-cascader
|
||||
v-model="form.cate"
|
||||
:options="cateOptions"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
:props="{ expandTrigger: 'hover', emitPath: false }"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="作者" prop="author">
|
||||
<el-input v-model="form.author" placeholder="请输入作者姓名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="简介" prop="desc">
|
||||
<el-input
|
||||
v-model="form.desc"
|
||||
:rows="4"
|
||||
type="textarea"
|
||||
placeholder="请输入简介"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="封面图片" prop="image">
|
||||
<div class="uploads">
|
||||
<!-- 已有图片显示 -->
|
||||
<div
|
||||
v-if="form.image && fileList.length === 0"
|
||||
class="existing-image"
|
||||
>
|
||||
<img
|
||||
:src="API_BASE_URL + form.image.replace(/\\\//g, '/')"
|
||||
alt="已有图片"
|
||||
/>
|
||||
<div class="image-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="previewExistingImage"
|
||||
>预览</el-button
|
||||
>
|
||||
<el-button type="danger" size="small" @click="removeExistingImage"
|
||||
>删除</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传组件 -->
|
||||
<el-upload
|
||||
v-model:file-list="fileList"
|
||||
:auto-upload="false"
|
||||
:before-upload="beforeImgUpload"
|
||||
list-type="picture-card"
|
||||
:limit="1"
|
||||
:show-file-list="fileList.length > 0"
|
||||
>
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
|
||||
<template #file="{ file }">
|
||||
<div>
|
||||
<img
|
||||
class="el-upload-list__item-thumbnail"
|
||||
:src="file.url"
|
||||
alt=""
|
||||
/>
|
||||
<span class="el-upload-list__item-actions">
|
||||
<span
|
||||
class="el-upload-list__item-preview"
|
||||
@click="handlePictureCardPreview(file)"
|
||||
>
|
||||
<el-icon>
|
||||
<ZoomIn />
|
||||
</el-icon>
|
||||
</span>
|
||||
<span
|
||||
class="el-upload-list__item-delete"
|
||||
@click="handleRemove(file)"
|
||||
>
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<el-drawer v-model="drawerVisible" width="60%" center>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
"
|
||||
>
|
||||
<img
|
||||
:src="drawerImageUrl"
|
||||
alt="Preview Image"
|
||||
style="max-width: 100%; max-height: 70vh; object-fit: contain"
|
||||
/>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<div class="upload-tip">
|
||||
<span>建议尺寸:250px × 140px</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<el-form-item label="是否转载" prop="is_trans">
|
||||
<el-radio-group v-model="form.is_trans">
|
||||
<el-radio-button :value="0">否</el-radio-button>
|
||||
<el-radio-button :value="1">是</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="发布地址" prop="transurl" v-if="form.is_trans === 1">
|
||||
<el-input v-model="form.transurl" placeholder="请输入转载文章地址" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内容" prop="content">
|
||||
<div class="editor-container">
|
||||
<WangEditor v-model="form.content" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-divider></el-divider>
|
||||
<span class="drawer-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button
|
||||
v-if="!isEdit"
|
||||
type="warning"
|
||||
@click="handleConfirm(0)"
|
||||
:loading="submitLoading"
|
||||
>草稿</el-button
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleConfirm(1)"
|
||||
:loading="submitLoading"
|
||||
>提交</el-button
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, watch, nextTick, onMounted, computed } from "vue";
|
||||
import { ElMessage, ElUpload } from "element-plus";
|
||||
import WangEditor from "@/views/components/WangEditor.vue";
|
||||
import { createArticle, editArticle, listCategories } from "@/api/article.js";
|
||||
import { uploadFile } from "@/api/file.js";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
model: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "saved"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const formRef = ref(null);
|
||||
|
||||
const handleBeforeClose = (done: () => void) => {
|
||||
handleCancel();
|
||||
done();
|
||||
};
|
||||
|
||||
const cateOptions = ref([]);
|
||||
|
||||
const form = reactive({
|
||||
title: "",
|
||||
author: "",
|
||||
cate: "",
|
||||
content: "",
|
||||
image: "",
|
||||
desc: "",
|
||||
is_trans: 0,
|
||||
transurl: null,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
title: [
|
||||
{ required: true, message: "请输入文章标题", trigger: "blur" },
|
||||
{
|
||||
min: 2,
|
||||
max: 200,
|
||||
message: "标题长度在 2 到 200 个字符",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
author: [
|
||||
{ required: true, message: "请输入作者", trigger: "blur" },
|
||||
{ max: 50, message: "作者姓名不能超过50个字符", trigger: "blur" },
|
||||
],
|
||||
content: [{ required: true, message: "请输入文章内容", trigger: "blur" }],
|
||||
};
|
||||
|
||||
// 获取分类列表
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await listCategories();
|
||||
if (res.code === 200) {
|
||||
const categories = Array.isArray(res.data) ? res.data : [];
|
||||
// 将分类转换为树形结构
|
||||
cateOptions.value = buildCategoryTree(categories);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取分类失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建分类树形结构
|
||||
function buildCategoryTree(categories, parentCid = 0) {
|
||||
return categories
|
||||
.filter((cate) => Number(cate.cid) === Number(parentCid))
|
||||
.map((cate) => {
|
||||
const children = buildCategoryTree(categories, cate.id);
|
||||
return {
|
||||
...cate,
|
||||
label: cate.name,
|
||||
value: cate.id,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 上传相关
|
||||
const fileList = ref<any[]>([]);
|
||||
const drawerVisible = ref(false);
|
||||
const drawerImageUrl = ref("");
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
function beforeImgUpload(file: File) {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||
if (!isImage) ElMessage.error("仅支持图片格式");
|
||||
if (!isLt10M) ElMessage.error("图片大小不能超过10MB");
|
||||
return isImage && isLt10M;
|
||||
}
|
||||
|
||||
function handlePictureCardPreview(file: any) {
|
||||
drawerImageUrl.value = file.url;
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
|
||||
function handleRemove(file: any) {
|
||||
fileList.value = [];
|
||||
form.image = "";
|
||||
}
|
||||
|
||||
function removeExistingImage() {
|
||||
form.image = "";
|
||||
}
|
||||
|
||||
function previewExistingImage() {
|
||||
const imagePath = form.image.replace(/\\\//g, "/");
|
||||
drawerImageUrl.value = API_BASE_URL + imagePath;
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
visible.value = newVal;
|
||||
if (newVal) {
|
||||
if (props.isEdit && props.model) {
|
||||
// 编辑模式,填充表单数据
|
||||
const modelData = props.model._raw || props.model;
|
||||
nextTick(() => {
|
||||
Object.assign(form, {
|
||||
title: modelData.title || "",
|
||||
author: modelData.author || "",
|
||||
cate: modelData.cate || "",
|
||||
content: modelData.content || "",
|
||||
desc: modelData.desc || "",
|
||||
is_trans: modelData.is_trans || 0,
|
||||
transurl: modelData.transurl || null,
|
||||
image: modelData.image || "",
|
||||
});
|
||||
fileList.value = []; // 重置文件列表
|
||||
});
|
||||
} else {
|
||||
// 新增模式,重置表单
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function handleCancel() {
|
||||
emit("update:modelValue", false);
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleConfirm(status = 1) {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
|
||||
try {
|
||||
let imageUrl = form.image;
|
||||
|
||||
// 如果有新选择的文件,先上传图片
|
||||
if (fileList.value.length > 0 && fileList.value[0].raw) {
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append("file", fileList.value[0].raw);
|
||||
uploadFormData.append("cate", "article");
|
||||
|
||||
const uploadRes = await uploadFile(uploadFormData);
|
||||
// 200=新文件上传成功,201=文件已存在(使用已有文件的链接)
|
||||
if (
|
||||
(uploadRes.code === 200 || uploadRes.code === 201) &&
|
||||
uploadRes.data &&
|
||||
uploadRes.data.url
|
||||
) {
|
||||
imageUrl = uploadRes.data.url;
|
||||
} else {
|
||||
ElMessage.error("图片上传失败:" + (uploadRes.msg || "未知错误"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加状态参数,0为草稿,1为提交
|
||||
const submitData = {
|
||||
title: form.title,
|
||||
author: form.author,
|
||||
cate: form.cate,
|
||||
content: form.content,
|
||||
desc: form.desc,
|
||||
image: imageUrl,
|
||||
is_trans: form.is_trans,
|
||||
transurl: form.is_trans === 1 ? form.transurl : null,
|
||||
status: status, // 0为草稿,1为提交
|
||||
};
|
||||
|
||||
// 根据新增/编辑模式选择不同的API调用函数
|
||||
const isCreateMode = !props.isEdit;
|
||||
let res;
|
||||
let resp;
|
||||
|
||||
if (isCreateMode) {
|
||||
// 新增模式,使用createArticle接口
|
||||
const createArticleWithCheck = async (ignoreSimilarity = false) => {
|
||||
const data = {
|
||||
...submitData,
|
||||
...(ignoreSimilarity && { ignore_similarity: 1 }),
|
||||
};
|
||||
return await createArticle(data);
|
||||
};
|
||||
|
||||
res = await createArticleWithCheck();
|
||||
resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
|
||||
// 检测到相似标题,显示确认对话框
|
||||
if (resp && resp.code === 409) {
|
||||
const similarArticles = resp.data?.similar_articles || [];
|
||||
// 构建带样式的确认消息
|
||||
const similarArticlesList = similarArticles
|
||||
.map(
|
||||
(article: any) =>
|
||||
`\n<div style="margin: 8px 0; padding: 8px; background: #f5f7fa; border-radius: 4px;">\n<div style="font-weight: bold; color: #303133;">${article.title}</div>\n<div style="color: #606266; font-size: 14px; margin-top: 4px;">相似度:${article.similarity}%</div>\n</div>\n`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const confirmMessage = `\n<div>\n\n${similarArticlesList} \n<p style="margin-top: 16px; color: #303133;">是否继续创建?</p>\n</div>\n`;
|
||||
|
||||
// 使用Element Plus的confirm对话框
|
||||
const { ElMessageBox } = await import("element-plus");
|
||||
try {
|
||||
await ElMessageBox.confirm(confirmMessage, "检测到相似标题", {
|
||||
confirmButtonText: "继续创建",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
// 用户确认继续,再次调用接口并忽略相似度检测
|
||||
res = await createArticleWithCheck(true);
|
||||
resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
} catch (error) {
|
||||
// 用户取消创建
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 编辑模式,使用editArticle接口
|
||||
// 获取文章ID
|
||||
const modelData = props.model._raw || props.model;
|
||||
const articleId = modelData.id;
|
||||
|
||||
if (!articleId) {
|
||||
ElMessage.error("文章ID不存在,无法更新");
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建编辑请求数据,不包含文章ID(ID通过URL参数传递)
|
||||
const editData = {
|
||||
...submitData,
|
||||
};
|
||||
|
||||
res = await editArticle(articleId, editData);
|
||||
resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
}
|
||||
|
||||
if (
|
||||
resp &&
|
||||
resp.code === 200 &&
|
||||
(resp.message === "success" || resp.msg === "success")
|
||||
) {
|
||||
ElMessage.success(
|
||||
status === 0
|
||||
? "保存草稿成功"
|
||||
: props.isEdit
|
||||
? "更新成功"
|
||||
: "创建成功",
|
||||
);
|
||||
emit("update:modelValue", false);
|
||||
emit("saved");
|
||||
} else {
|
||||
ElMessage.error(
|
||||
(resp && resp.message) ||
|
||||
(resp && resp.msg) ||
|
||||
(status === 0
|
||||
? "保存草稿失败"
|
||||
: props.isEdit
|
||||
? "更新失败"
|
||||
: "创建失败"),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
status === 0
|
||||
? "保存草稿失败"
|
||||
: props.isEdit
|
||||
? "更新失败"
|
||||
: "创建失败",
|
||||
);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
// 重置表单
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields();
|
||||
}
|
||||
// 重置文件列表
|
||||
fileList.value = [];
|
||||
// 重置表单数据
|
||||
Object.assign(form, {
|
||||
title: "",
|
||||
author: "",
|
||||
cate: "",
|
||||
content: "",
|
||||
image: "",
|
||||
desc: "",
|
||||
is_trans: 0,
|
||||
transurl: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 暴露重置方法(如果需要的话)
|
||||
defineExpose({
|
||||
resetForm: () => {
|
||||
formRef.value?.resetFields();
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.editor-container {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
:deep(.w-e-text),
|
||||
:deep(.w-e-text-container) {
|
||||
p {
|
||||
color: #1a1a2e !important;
|
||||
margin: 0.5em 0 !important;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #1a1a2e !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3973ff !important;
|
||||
text-decoration: underline !important;
|
||||
|
||||
&:hover {
|
||||
color: #3973ff !important;
|
||||
opacity: 0.8 !important;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #f5f7fa !important;
|
||||
color: #1a1a2e !important;
|
||||
border: 1px solid #e4e7ed !important;
|
||||
padding: 2px 6px !important;
|
||||
border-radius: 3px !important;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #f5f7fa !important;
|
||||
border: 1px solid #e4e7ed !important;
|
||||
color: #1a1a2e !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 12px 16px;
|
||||
margin: 12px 0;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #3973ff !important;
|
||||
background-color: #f5f7fa !important;
|
||||
color: #606266 !important;
|
||||
padding: 0.6em 1.2em !important;
|
||||
margin: 1em 0 !important;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse !important;
|
||||
border: 1px solid #e4e7ed !important;
|
||||
|
||||
th, td {
|
||||
border: 1px solid #e4e7ed !important;
|
||||
background-color: #ffffff !important;
|
||||
color: #1a1a2e !important;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f5f7fa !important;
|
||||
}
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
color: #1a1a2e !important;
|
||||
padding-left: 24px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
li {
|
||||
color: #1a1a2e !important;
|
||||
line-height: 1.8;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: 1px solid #e4e7ed !important;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.uploads {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.existing-image {
|
||||
position: relative;
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.existing-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.existing-image:hover .image-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
::deep(.el-message-box) {
|
||||
width: 800px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,347 +0,0 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="文章预览" size="60%">
|
||||
<div class="article-preview">
|
||||
<div class="article-header">
|
||||
<h1 class="article-title">{{ model?.title || "无标题" }}</h1>
|
||||
<div class="article-meta">
|
||||
<span class="meta-item">
|
||||
<i class="el-icon-price-tag"></i>
|
||||
<el-tag type="primary">{{ getCategoryName(model?.cate) }}</el-tag>
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="el-icon-user"></i>
|
||||
作者:{{ model?.author }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="el-icon-view"></i>
|
||||
发布人:{{ model?.publisher || "暂未发布" }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="el-icon-view"></i>
|
||||
阅读量:{{ model?.views || 0 }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="el-icon-view"></i>
|
||||
收藏量:{{ model?.likes || 0 }}
|
||||
</span>
|
||||
<span class="meta-item" v-if="model?.publish_time">
|
||||
<i class="el-icon-time"></i>
|
||||
{{ formatDate(model.publish_time) }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="el-icon-view"></i>
|
||||
创建日期:{{ model?.create_time }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-content">
|
||||
<div
|
||||
v-if="model?.content"
|
||||
v-html="model.content"
|
||||
class="content-html"
|
||||
></div>
|
||||
<div v-else class="no-content">
|
||||
<el-empty description="暂无内容"></el-empty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { listCategories } from "@/api/article";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
|
||||
const categoryOptions = ref([]);
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
model: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
// 获取分类列表
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await listCategories({ page: 1, limit: 1000 });
|
||||
if (res.code === 200) {
|
||||
categoryOptions.value = Array.isArray(res.data)
|
||||
? res.data
|
||||
: Array.isArray(res.data?.list)
|
||||
? res.data.list
|
||||
: [];
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "获取分类列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取分类列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 根据分类ID获取分类名称
|
||||
const getCategoryName = (cate) => {
|
||||
if (!cate) return "无分类";
|
||||
// 确保比较时类型一致
|
||||
const cateId = Number(cate);
|
||||
const category = categoryOptions.value.find(
|
||||
(item) => Number(item.id) === cateId,
|
||||
);
|
||||
return category?.name || "无分类";
|
||||
};
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const visible = ref(false);
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
visible.value = newVal;
|
||||
},
|
||||
);
|
||||
|
||||
// 监听visible变化,同步给父组件
|
||||
watch(visible, (newVal) => {
|
||||
emit("update:modelValue", newVal);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.article-preview {
|
||||
padding: 20px;
|
||||
|
||||
.article-header {
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.article-title {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.article-content {
|
||||
/* 确保article-content直接子元素的样式 */
|
||||
& > div {
|
||||
line-height: 1.8;
|
||||
color: #606266;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 支持所有子元素的内联样式 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 段落样式 */
|
||||
p {
|
||||
margin: 10px 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 20px 0 10px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
}
|
||||
h5 {
|
||||
font-size: 16px;
|
||||
}
|
||||
h6 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 列表样式 */
|
||||
ul,
|
||||
ol {
|
||||
margin: 10px 0;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
/* 引用样式 */
|
||||
blockquote {
|
||||
border-left: 4px solid #ebeef5;
|
||||
padding-left: 15px;
|
||||
margin: 15px 0;
|
||||
color: #909399;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 代码样式 */
|
||||
:deep(code) {
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
color: #f06b6b;
|
||||
}
|
||||
|
||||
/* 代码块样式 */
|
||||
:deep(pre) {
|
||||
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d3f 100%);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 图片样式 */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #ebeef5;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保内联样式能够生效 */
|
||||
[style] {
|
||||
/* 允许内联样式正常工作 */
|
||||
all: unset;
|
||||
/* 重新应用基本样式 */
|
||||
line-height: 1.8;
|
||||
color: #606266;
|
||||
/* 允许特定内联样式覆盖 */
|
||||
&[style*="text-align"] {
|
||||
text-align: var(--text-align, inherit) !important;
|
||||
}
|
||||
&[style*="color"] {
|
||||
color: var(--color, inherit) !important;
|
||||
}
|
||||
&[style*="font-size"] {
|
||||
font-size: var(--font-size, inherit) !important;
|
||||
}
|
||||
&[style*="font-weight"] {
|
||||
font-weight: var(--font-weight, inherit) !important;
|
||||
}
|
||||
&[style*="font-style"] {
|
||||
font-style: var(--font-style, inherit) !important;
|
||||
}
|
||||
&[style*="text-decoration"] {
|
||||
text-decoration: var(--text-decoration, inherit) !important;
|
||||
}
|
||||
&[style*="margin"] {
|
||||
margin: var(--margin, inherit) !important;
|
||||
}
|
||||
&[style*="padding"] {
|
||||
padding: var(--padding, inherit) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 直接支持text-align属性 */
|
||||
[align] {
|
||||
text-align: attr(align) !important;
|
||||
}
|
||||
|
||||
/* 确保content-html类的样式 */
|
||||
.content-html {
|
||||
& > * {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
& > h1,
|
||||
& > h2,
|
||||
& > h3,
|
||||
& > h4,
|
||||
& > h5,
|
||||
& > h6 {
|
||||
margin: 20px 0 10px 0;
|
||||
}
|
||||
|
||||
& > p {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* 支持居中样式 */
|
||||
& > p[style*="text-align: center"],
|
||||
& > p[align="center"] {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* 图片居中 */
|
||||
& > p[style*="text-align: center"] img,
|
||||
& > p[align="center"] img {
|
||||
display: inline-block;
|
||||
margin: 10px auto;
|
||||
}
|
||||
}
|
||||
|
||||
.no-content {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,670 +0,0 @@
|
||||
<template>
|
||||
<div class="cms-articles">
|
||||
<div class="articles-container">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">新增文章</el-button>
|
||||
<el-button @click="handleRefresh">刷新</el-button>
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索文章标题"
|
||||
clearable
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #append>
|
||||
<el-button :icon="Search" @click="handleSearch" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<div class="filters">
|
||||
<!-- 根据分类筛选 -->
|
||||
<el-select
|
||||
v-model="categoryFilter"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
style="width: 150px; margin-right: 10px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in categoryOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<!-- 根据日期筛选 -->
|
||||
<el-date-picker
|
||||
v-model="dateFilter"
|
||||
type="daterange"
|
||||
value-format="yyyy-MM-dd"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 240px; margin-right: 10px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文章列表 -->
|
||||
<el-table
|
||||
:data="articleList"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
border
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column
|
||||
prop="title"
|
||||
label="标题"
|
||||
min-width="400"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; align-items: center; gap: 4px">
|
||||
<!-- 置顶标签 -->
|
||||
<el-tag
|
||||
v-if="row.top === 1"
|
||||
type="danger"
|
||||
size="small"
|
||||
effect="dark"
|
||||
>
|
||||
置顶
|
||||
</el-tag>
|
||||
<!-- 推荐标签 -->
|
||||
<el-tag
|
||||
v-if="row.recommend === 1"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="dark"
|
||||
>
|
||||
推荐
|
||||
</el-tag>
|
||||
<!-- 标题链接 -->
|
||||
<el-link
|
||||
type="primary"
|
||||
@click="handleView(row)"
|
||||
underline="never"
|
||||
>
|
||||
{{ row.title }}
|
||||
</el-link>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="cate" label="文章分类" width="120" />
|
||||
<el-table-column prop="author" label="作者" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="views"
|
||||
label="浏览量"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="likes"
|
||||
label="点赞量"
|
||||
width="100"
|
||||
align="center"
|
||||
/>
|
||||
<el-table-column prop="publish_date" label="发布时间" width="160" />
|
||||
<el-table-column prop="update_time" label="更新时间" width="160" />
|
||||
<el-table-column prop="publisher" label="发布人" width="120" />
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status != 2 && row.status != 0"
|
||||
size="small"
|
||||
type=""
|
||||
@click="handlePulish(row)"
|
||||
>发布</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status != 2"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleEdit(row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status != 2"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleUnPulish(row)"
|
||||
>下架</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 && row.recommend === 0"
|
||||
size="small"
|
||||
type=""
|
||||
@click="handleRecommend(row)"
|
||||
>推荐</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 && row.recommend === 1"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleUnRecommend(row)"
|
||||
>取消推荐</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 && row.top === 0"
|
||||
size="small"
|
||||
type=""
|
||||
@click="handleTop(row)"
|
||||
>置顶</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 && row.top === 1"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleUnTop(row)"
|
||||
>取消置顶</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<Edit
|
||||
v-model="dialogVisible"
|
||||
:is-edit="isEdit"
|
||||
:model="currentRow"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<!-- 预览抽屉 -->
|
||||
<Preview v-model="previewVisible" :model="currentRow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import Edit from "./components/edit.vue";
|
||||
import Preview from "./components/preview.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
listArticles,
|
||||
deleteArticle,
|
||||
listCategories,
|
||||
publishArticle,
|
||||
unPublishArticle,
|
||||
getArticle,
|
||||
articleRecommend,
|
||||
articleTop,
|
||||
unArticleRecommend,
|
||||
unArticleTop,
|
||||
} from "@/api/article.js";
|
||||
|
||||
const loading = ref(false);
|
||||
const articleList = ref([]);
|
||||
const searchQuery = ref("");
|
||||
const categoryFilter = ref("");
|
||||
const categoryOptions = ref([]);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const total = ref(0);
|
||||
const dialogVisible = ref(false);
|
||||
const previewVisible = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const currentRow = ref(null);
|
||||
|
||||
// 使用 auth store 获取用户信息
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 获取用户信息
|
||||
const userInfo = authStore.user;
|
||||
if (userInfo && userInfo.id) {
|
||||
// console.log('用户名:', userInfo.account || userInfo.name);
|
||||
// console.log('用户ID:', userInfo.id);
|
||||
// console.log('角色:', userInfo.role);
|
||||
} else {
|
||||
console.log("未找到用户信息或用户未登录");
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
function getStatusText(status) {
|
||||
const statusMap = {
|
||||
0: "草稿",
|
||||
1: "待审核",
|
||||
2: "已发布",
|
||||
3: "已隐藏",
|
||||
};
|
||||
return statusMap[status] || "未知";
|
||||
}
|
||||
|
||||
// 获取状态对应的标签类型
|
||||
function getStatusType(status) {
|
||||
const typeMap = {
|
||||
0: "info",
|
||||
1: "warning",
|
||||
2: "success",
|
||||
3: "danger",
|
||||
};
|
||||
return typeMap[status] || "info";
|
||||
}
|
||||
|
||||
// 获取推荐文本
|
||||
function getRecommendText(status) {
|
||||
const statusMap = {
|
||||
0: "未推荐",
|
||||
1: "推荐",
|
||||
};
|
||||
return statusMap[status] || "未知";
|
||||
}
|
||||
|
||||
// 获取推荐对应的标签类型
|
||||
function getRecommendType(status) {
|
||||
const typeMap = {
|
||||
0: "info",
|
||||
1: "success",
|
||||
};
|
||||
return typeMap[status] || "info";
|
||||
}
|
||||
|
||||
// 获取文章列表
|
||||
async function fetchArticleList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
keyword: searchQuery.value,
|
||||
cate: categoryFilter.value,
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
|
||||
const res = await listArticles(params);
|
||||
|
||||
if (res.code === 200) {
|
||||
articleList.value = res.data.list || [];
|
||||
total.value = res.data.total || 0;
|
||||
} else {
|
||||
console.error("获取文章列表失败:", res.msg);
|
||||
ElMessage.error(res.msg || "获取文章列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("请求异常:", error);
|
||||
ElMessage.error("网络请求失败,请稍后重试");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
// 处理筛选变化
|
||||
function handleFilterChange() {
|
||||
currentPage.value = 1;
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
// 处理每页条数变化
|
||||
function handleSizeChange(val) {
|
||||
pageSize.value = val;
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
// 处理当前页变化
|
||||
function handleCurrentChange(val) {
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false;
|
||||
currentRow.value = null;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(row) {
|
||||
isEdit.value = true;
|
||||
// 获取详情
|
||||
getArticle(row.id).then((res) => {
|
||||
const resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
if (resp && resp.code === 200 && resp.data) {
|
||||
const m = resp.data;
|
||||
currentRow.value = {
|
||||
id: m.id,
|
||||
title: m.title || "",
|
||||
author: m.author || "",
|
||||
cate: m.cate || "",
|
||||
content: m.content || "",
|
||||
desc: m.desc || "",
|
||||
publish_time: m.publish_time || null,
|
||||
_raw: m,
|
||||
};
|
||||
} else {
|
||||
currentRow.value = { ...row };
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
function handleView(row) {
|
||||
// 获取最新详情再预览
|
||||
getArticle(row.id).then((res) => {
|
||||
const resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
if (resp && resp.code === 200 && resp.data) {
|
||||
const m = resp.data;
|
||||
currentRow.value = {
|
||||
id: m.id,
|
||||
title: m.title || "",
|
||||
author: m.author || "",
|
||||
cate: m.cate || "",
|
||||
content: m.content || "",
|
||||
desc: m.desc || "",
|
||||
view_count: m.view_count || 0,
|
||||
publisher: m.publisher || "",
|
||||
create_time: m.create_time || null,
|
||||
publish_time: m.publish_time || null,
|
||||
update_time: m.update_time || null,
|
||||
_raw: m,
|
||||
};
|
||||
previewVisible.value = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
ElMessageBox.confirm("确定要删除这篇文章吗?此操作不可恢复。", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deleteArticle(row.id).then((res) => {
|
||||
const resp =
|
||||
res && typeof res.code !== "undefined"
|
||||
? res
|
||||
: res && res.data
|
||||
? res.data
|
||||
: res;
|
||||
if (resp && resp.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error((resp && resp.msg) || "删除失败");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const uid = userInfo.id;
|
||||
|
||||
//发布文章
|
||||
function handlePulish(row) {
|
||||
ElMessageBox.confirm("确认发布该文章吗?发布后将在前台显示。", "确认发布", {
|
||||
confirmButtonText: "确认发布",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
publishArticle(row.id, uid)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("发布成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "发布失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("发布失败:", error);
|
||||
ElMessage.error(error.msg || "发布失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// 下架文章
|
||||
function handleUnPulish(row) {
|
||||
ElMessageBox.confirm("确认下架该文章吗?", "确认", {
|
||||
confirmButtonText: "确认",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
unPublishArticle(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("下架成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "下架失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("下架失败:", error);
|
||||
ElMessage.error(error.msg || "下架失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
//推荐文章
|
||||
function handleRecommend(row) {
|
||||
ElMessageBox.confirm("确认推荐该文章吗?推荐后将在前台显示。", "确认推荐", {
|
||||
confirmButtonText: "确认推荐",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
articleRecommend(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("推荐成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "推荐失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("推荐失败:", error);
|
||||
ElMessage.error(error.msg || "推荐失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// 取消推荐文章
|
||||
function handleUnRecommend(row) {
|
||||
ElMessageBox.confirm("确认取消推荐该文章吗?", "确认", {
|
||||
confirmButtonText: "确认",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
unArticleRecommend(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("取消推荐成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "取消推荐失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("取消推荐失败:", error);
|
||||
ElMessage.error(error.msg || "取消推荐失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
//置顶文章
|
||||
function handleTop(row) {
|
||||
ElMessageBox.confirm("确认置顶该文章吗?置顶后将在前台显示。", "确认置顶", {
|
||||
confirmButtonText: "确认置顶",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
articleTop(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("置顶成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "置顶失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("置顶失败:", error);
|
||||
ElMessage.error(error.msg || "置顶失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// 取消置顶文章
|
||||
function handleUnTop(row) {
|
||||
ElMessageBox.confirm("确认取消置顶该文章吗?", "确认", {
|
||||
confirmButtonText: "确认",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
unArticleTop(row.id)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("取消置顶成功");
|
||||
fetchArticleList();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "取消置顶失败");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("取消置顶失败:", error);
|
||||
ElMessage.error(error.msg || "取消置顶失败");
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage.value = 1;
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
searchQuery.value = "";
|
||||
categoryFilter.value = "";
|
||||
currentPage.value = 1;
|
||||
fetchArticleList();
|
||||
}
|
||||
|
||||
// 处理保存成功回调
|
||||
function onSaved() {
|
||||
dialogVisible.value = false;
|
||||
fetchArticleList();
|
||||
ElMessage.success("保存成功");
|
||||
}
|
||||
|
||||
// 获取分类列表
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await listCategories({ page: 1, limit: 1000 });
|
||||
|
||||
if (res && res.code === 200) {
|
||||
if (res.data && Array.isArray(res.data.list)) {
|
||||
categoryOptions.value = res.data.list;
|
||||
} else if (Array.isArray(res.data)) {
|
||||
categoryOptions.value = res.data;
|
||||
} else if (Array.isArray(res.list)) {
|
||||
categoryOptions.value = res.list;
|
||||
}
|
||||
|
||||
if (categoryOptions.value.length === 0) {
|
||||
ElMessage.warning("未获取到分类数据");
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "获取分类列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取分类列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchArticleList(),
|
||||
fetchCategories(), // 获取分类数据
|
||||
]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.cms-articles {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
height: 100%;
|
||||
background: var(--el-bg-color);
|
||||
|
||||
.articles-container {
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.search-bar {
|
||||
margin-left: auto;
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,332 +0,0 @@
|
||||
<template>
|
||||
<!-- 添加/编辑Banner对话框 -->
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleDialogClose"
|
||||
>
|
||||
<el-form
|
||||
:model="currentBanner"
|
||||
label-width="100px"
|
||||
:rules="formRules"
|
||||
ref="bannerFormRef"
|
||||
>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input
|
||||
v-model="currentBanner.title"
|
||||
placeholder="请输入Banner标题"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="简介" prop="desc">
|
||||
<el-input
|
||||
v-model="currentBanner.desc"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入Banner简介"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="跳转地址" prop="url">
|
||||
<el-input
|
||||
v-model="currentBanner.url"
|
||||
placeholder="例如:https://www.example.com 或 /page/detail"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
支持外部链接(http://)和内部路由(/)
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Banner图片" prop="image">
|
||||
<el-upload
|
||||
class="image-uploader"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleImageSuccess"
|
||||
:on-error="handleImageError"
|
||||
:before-upload="beforeImageUpload"
|
||||
accept="*"
|
||||
>
|
||||
<img
|
||||
v-if="currentBanner.image"
|
||||
:src="getImageUrl(currentBanner.image)"
|
||||
class="image-preview"
|
||||
/>
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="image-uploader-icon"><Plus /></el-icon>
|
||||
<div class="el-upload__text">点击上传图片</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
建议尺寸:1920x600,支持 jpg、png、gif 格式,大小不超过 5MB
|
||||
</div>
|
||||
<el-button
|
||||
v-if="currentBanner.image"
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleRemoveImage"
|
||||
style="margin-top: 8px;"
|
||||
>
|
||||
删除图片
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number
|
||||
v-model="currentBanner.sort"
|
||||
:min="0"
|
||||
placeholder="数字越小越靠前"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
数字越小,排序越靠前
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage, ElForm } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
|
||||
// 定义Banner数据类型
|
||||
interface Banner {
|
||||
id: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
image: string;
|
||||
sort: number;
|
||||
create_time?: number;
|
||||
update_time?: number;
|
||||
delete_time?: number;
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
banner: Partial<Banner> | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
banner: null,
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "save", banner: Partial<Banner>): void;
|
||||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
// 表单引用
|
||||
const bannerFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
|
||||
// 当前操作的Banner
|
||||
const currentBanner = ref<Partial<Banner>>({
|
||||
id: 0,
|
||||
title: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
});
|
||||
|
||||
// 上传配置
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
const uploadUrl = ref(API_BASE_URL + "/platform/uploadfiles");
|
||||
const uploadHeaders = ref({
|
||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||
});
|
||||
|
||||
// 对话框标题
|
||||
const dialogTitle = computed(() => {
|
||||
return props.banner?.id && props.banner.id > 0 ? "编辑Banner" : "添加Banner";
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = ref({
|
||||
title: [{ required: true, message: "请输入Banner标题", trigger: "blur" }],
|
||||
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
||||
});
|
||||
|
||||
// 监听props变化,更新当前Banner
|
||||
watch(
|
||||
() => props.banner,
|
||||
(newBanner) => {
|
||||
if (newBanner) {
|
||||
currentBanner.value = {
|
||||
...newBanner,
|
||||
};
|
||||
} else {
|
||||
// 重置表单
|
||||
currentBanner.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听props.visible变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && (!props.banner || !props.banner.id)) {
|
||||
// 新增时重置表单
|
||||
currentBanner.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
};
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = (value: boolean) => {
|
||||
if (!value) {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传前校验
|
||||
const beforeImageUpload = (file: any) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// 图片上传成功
|
||||
const handleImageSuccess = (response: any) => {
|
||||
if (response.code === 200) {
|
||||
// 直接保存相对路径,不拼接API_BASE_URL
|
||||
currentBanner.value.image = response.data.url || response.data.path;
|
||||
ElMessage.success("图片上传成功");
|
||||
} else {
|
||||
ElMessage.error(response.msg || "图片上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传失败
|
||||
const handleImageError = () => {
|
||||
ElMessage.error("图片上传失败,请重试");
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = () => {
|
||||
currentBanner.value.image = "";
|
||||
ElMessage.success("图片已删除");
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于预览)
|
||||
const getImageUrl = (imagePath: string) => {
|
||||
if (!imagePath) return "";
|
||||
// 如果是绝对路径,直接返回
|
||||
if (imagePath.startsWith("http")) return imagePath;
|
||||
// 如果是相对路径,拼接API基础地址
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 保存Banner
|
||||
const handleSave = async () => {
|
||||
// 表单验证
|
||||
if (!bannerFormRef.value) return;
|
||||
const valid = await bannerFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 准备提交数据
|
||||
const payload = { ...currentBanner.value };
|
||||
|
||||
// 触发保存事件
|
||||
emit("save", payload);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 图片上传样式 */
|
||||
.image-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.image-uploader-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.el-upload__text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,301 +0,0 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>Banner管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAddBanner">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加Banner
|
||||
</el-button>
|
||||
<el-button @click="refresh" :loading="loading">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="bannerList"
|
||||
style="width: 100%"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
|
||||
<el-table-column prop="title" label="标题" min-width="150" />
|
||||
|
||||
<el-table-column label="图片" width="200" align="center">
|
||||
<template #default="scope">
|
||||
<el-image
|
||||
v-if="scope.row.image"
|
||||
:src="getImageUrl(scope.row.image)"
|
||||
:preview-src-list="[getImageUrl(scope.row.image)]"
|
||||
:preview-teleported="true"
|
||||
fit="cover"
|
||||
style="width: 100px; height: 60px; border-radius: 4px; cursor: pointer;"
|
||||
/>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="desc" label="简介" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.desc">{{ scope.row.desc }}</span>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="url" label="跳转地址" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<el-link v-if="scope.row.url" :href="scope.row.url" target="_blank" type="primary">
|
||||
{{ scope.row.url }}
|
||||
</el-link>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="sort"
|
||||
label="排序"
|
||||
width="100"
|
||||
align="center"
|
||||
sortable
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag>{{ scope.row.sort || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text @click="handleEditBanner(scope.row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="danger"
|
||||
@click="handleDeleteBanner(scope.row)"
|
||||
>
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 引入编辑组件 -->
|
||||
<BannerEdit
|
||||
v-model:visible="dialogVisible"
|
||||
:banner="dialogBanner"
|
||||
@save="handleBannerSave"
|
||||
@cancel="handleBannerCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Edit, Delete, Refresh } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getBanners,
|
||||
createBanner,
|
||||
editBanner,
|
||||
deleteBanner,
|
||||
} from "@/api/banner";
|
||||
import BannerEdit from "./components/edit.vue";
|
||||
|
||||
// 定义Banner数据类型
|
||||
interface Banner {
|
||||
id: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
image: string;
|
||||
sort: number;
|
||||
create_time?: number;
|
||||
update_time?: number;
|
||||
delete_time?: number;
|
||||
}
|
||||
|
||||
// Banner列表
|
||||
const bannerList = ref<Banner[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 对话框相关变量
|
||||
const dialogVisible = ref(false);
|
||||
const dialogBanner = ref<Partial<Banner> | null>(null);
|
||||
|
||||
// 获取Banner列表
|
||||
const fetchBanners = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await getBanners();
|
||||
if (result.code === 200) {
|
||||
bannerList.value = result.data || [];
|
||||
} else {
|
||||
ElMessage.error("获取Banner列表失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取Banner列表失败: " + (error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新界面
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await fetchBanners();
|
||||
ElMessage.success("刷新成功");
|
||||
} catch (error) {
|
||||
ElMessage.error("刷新失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加Banner
|
||||
const handleAddBanner = () => {
|
||||
dialogBanner.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
desc: "",
|
||||
url: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑Banner
|
||||
const handleEditBanner = (banner: Banner) => {
|
||||
dialogBanner.value = { ...banner };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除Banner
|
||||
const handleDeleteBanner = (banner: Banner) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除Banner "${banner.title}" 吗?`,
|
||||
"确认删除",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const result = await deleteBanner(banner.id);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchBanners();
|
||||
} else {
|
||||
ElMessage.error("删除失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("删除失败: " + (error as Error).message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理Banner保存
|
||||
const handleBannerSave = async (banner: Partial<Banner>) => {
|
||||
try {
|
||||
const payload = { ...banner };
|
||||
|
||||
// 判断是新增还是编辑
|
||||
if (!banner.id || banner.id === 0) {
|
||||
// 新增Banner
|
||||
const result = await createBanner(payload as Banner);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "Banner添加成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchBanners();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "添加失败");
|
||||
}
|
||||
} else {
|
||||
// 编辑已存在的Banner
|
||||
const result = await editBanner(banner.id!, payload as Banner);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "更新成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchBanners();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "更新失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("操作失败: " + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理Banner取消
|
||||
const handleBannerCancel = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于显示)
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
const getImageUrl = (imagePath: string) => {
|
||||
if (!imagePath) return '';
|
||||
// 如果是绝对路径,直接返回
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
// 如果是相对路径,拼接API基础地址
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 组件挂载时加载Banner列表
|
||||
onMounted(() => {
|
||||
fetchBanners();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
:deep(.el-table) {
|
||||
border-radius: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body td) {
|
||||
padding: 12px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -1,150 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="600px"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="需求标题" required>
|
||||
<el-input v-model="form.title" placeholder="请输入需求标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="需求描述" required>
|
||||
<el-input
|
||||
v-model="form.desc"
|
||||
type="textarea"
|
||||
rows="4"
|
||||
placeholder="请输入需求描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请人">
|
||||
<el-input v-model="form.applicant" placeholder="请输入申请人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="form.phone" placeholder="请输入电话" />
|
||||
</el-form-item>
|
||||
<el-form-item label="需求状态">
|
||||
<el-select v-model="form.status" placeholder="请选择状态">
|
||||
<el-option label="待处理" :value="1" />
|
||||
<el-option label="处理中" :value="2" />
|
||||
<el-option label="已完成" :value="3" />
|
||||
<el-option label="已拒绝" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { addDemand, editDemand } from "@/api/demand";
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "新增需求",
|
||||
},
|
||||
demand: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
id: "",
|
||||
title: "",
|
||||
desc: "",
|
||||
applicant: "",
|
||||
status: 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(["close", "submit"]);
|
||||
|
||||
// 状态
|
||||
const dialogVisible = ref(props.visible);
|
||||
const dialogTitle = ref(props.title);
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
id: "",
|
||||
title: "",
|
||||
desc: "",
|
||||
applicant: "",
|
||||
status: 1,
|
||||
});
|
||||
|
||||
// 监听props变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVal) => {
|
||||
dialogVisible.value = newVal;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
(newVal) => {
|
||||
dialogTitle.value = newVal;
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.demand,
|
||||
(newVal) => {
|
||||
Object.assign(form, newVal);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
emit("close");
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
// 表单验证
|
||||
if (!form.title || !form.desc) {
|
||||
ElMessage.warning("请填写必填项");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (form.id) {
|
||||
// 编辑
|
||||
result = await editDemand(form.id, form);
|
||||
} else {
|
||||
// 新增
|
||||
result = await addDemand(form);
|
||||
}
|
||||
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(form.id ? "编辑成功" : "新增成功");
|
||||
emit("submit", { ...form });
|
||||
} else {
|
||||
ElMessage.error(result.msg || "操作失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("网络错误,请稍后重试");
|
||||
console.error("提交表单失败:", error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -1,333 +0,0 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>需求管理</h2>
|
||||
<div>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增需求
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleRefush">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="search-bar">
|
||||
<el-form :inline="true" :model="searchForm" class="mb-4">
|
||||
<el-form-item label="需求标题">
|
||||
<el-input
|
||||
v-model="searchForm.title"
|
||||
placeholder="请输入需求标题"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="需求状态">
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="请选择状态"
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="待处理" :value="1" />
|
||||
<el-option label="处理中" :value="2" />
|
||||
<el-option label="已完成" :value="3" />
|
||||
<el-option label="已拒绝" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 需求列表 -->
|
||||
<el-table v-loading="loading" :data="demandList" style="width: 100%" border>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="title" label="标题" min-width="100" />
|
||||
<el-table-column
|
||||
prop="desc"
|
||||
label="描述"
|
||||
min-width="300"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="applicant" label="申请人" width="120" />
|
||||
<el-table-column prop="phone" label="电话" width="180" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusType(scope.row.status)">
|
||||
{{ getStatusText(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="handleEdit(scope.row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.current"
|
||||
v-model:page-size="pagination.size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 需求表单组件 -->
|
||||
<Edit
|
||||
:visible="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
:demand="form"
|
||||
@close="handleFormClose"
|
||||
@submit="handleFormSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { Plus, Refresh } from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import Edit from "./components/edit.vue";
|
||||
import { getDemandList, deleteDemand } from "@/api/demand";
|
||||
|
||||
// 状态管理
|
||||
const loading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref("新增需求");
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
title: "",
|
||||
status: "",
|
||||
});
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
size: 10,
|
||||
});
|
||||
|
||||
// 总数据量
|
||||
const total = ref(0);
|
||||
|
||||
// 需求列表
|
||||
const demandList = ref<any[]>([]);
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
id: "",
|
||||
title: "",
|
||||
desc: "",
|
||||
applicant: "",
|
||||
status: 1,
|
||||
});
|
||||
|
||||
// 状态类型映射
|
||||
const getStatusType = (status: string | number) => {
|
||||
const typeMap: Record<string | number, string> = {
|
||||
1: "info",
|
||||
2: "warning",
|
||||
3: "success",
|
||||
4: "danger",
|
||||
};
|
||||
return typeMap[status] || "info";
|
||||
};
|
||||
|
||||
// 状态文本映射
|
||||
const getStatusText = (status: string | number) => {
|
||||
const textMap: Record<string | number, string> = {
|
||||
1: "待处理",
|
||||
2: "处理中",
|
||||
3: "已完成",
|
||||
4: "已拒绝",
|
||||
};
|
||||
return textMap[status] || String(status);
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1;
|
||||
fetchDemandList();
|
||||
};
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.title = "";
|
||||
searchForm.status = "";
|
||||
pagination.current = 1;
|
||||
fetchDemandList();
|
||||
};
|
||||
|
||||
// 获取需求列表
|
||||
const fetchDemandList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getDemandList();
|
||||
|
||||
if (data.code === 200) {
|
||||
let filteredList = [...data.list];
|
||||
|
||||
// 根据搜索条件过滤数据
|
||||
if (searchForm.title) {
|
||||
filteredList = filteredList.filter((item) =>
|
||||
item.title.includes(searchForm.title),
|
||||
);
|
||||
}
|
||||
if (searchForm.status) {
|
||||
filteredList = filteredList.filter(
|
||||
(item) => item.status === searchForm.status,
|
||||
);
|
||||
}
|
||||
|
||||
// 模拟分页
|
||||
total.value = filteredList.length;
|
||||
const start = (pagination.current - 1) * pagination.size;
|
||||
const end = start + pagination.size;
|
||||
demandList.value = filteredList.slice(start, end);
|
||||
} else {
|
||||
ElMessage.error("获取需求列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("网络错误,请稍后重试");
|
||||
console.error("获取需求列表失败:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 分页大小变化
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.size = size;
|
||||
fetchDemandList();
|
||||
};
|
||||
|
||||
// 页码变化
|
||||
const handleCurrentChange = (current: number) => {
|
||||
pagination.current = current;
|
||||
fetchDemandList();
|
||||
};
|
||||
|
||||
// 新增需求
|
||||
const handleAdd = () => {
|
||||
dialogTitle.value = "新增需求";
|
||||
Object.assign(form, {
|
||||
id: "",
|
||||
title: "",
|
||||
desc: "",
|
||||
applicant: "",
|
||||
status: 1,
|
||||
});
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑需求
|
||||
const handleEdit = (row: any) => {
|
||||
dialogTitle.value = "编辑需求";
|
||||
Object.assign(form, row);
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除需求
|
||||
const handleDelete = async (id: number) => {
|
||||
ElMessageBox.confirm("确定要删除这个需求吗?", "删除确认", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
const result = await deleteDemand(id);
|
||||
|
||||
if (result.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchDemandList();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("网络错误,请稍后重试");
|
||||
console.error("删除需求失败:", error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 取消删除
|
||||
});
|
||||
};
|
||||
|
||||
// 刷新需求
|
||||
const handleRefush = () => {
|
||||
fetchDemandList();
|
||||
};
|
||||
|
||||
// 处理表单关闭
|
||||
const handleFormClose = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 处理表单提交
|
||||
const handleFormSubmit = () => {
|
||||
dialogVisible.value = false;
|
||||
fetchDemandList();
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchDemandList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -1,185 +0,0 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>租户域名审核</h2>
|
||||
<el-button @click="fetchData" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="searchForm.sub_domain"
|
||||
placeholder="搜索二级域名"
|
||||
clearable
|
||||
style="width: 200px; margin-right: 10px;"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 120px; margin-right: 10px;">
|
||||
<el-option label="审核中" :value="0" />
|
||||
<el-option label="已生效" :value="1" />
|
||||
<el-option label="已禁用" :value="2" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="tableData"
|
||||
style="width: 100%"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="tenant_name" label="租户名称" min-width="150" />
|
||||
<el-table-column prop="sub_domain" label="二级域名前缀" width="150" />
|
||||
<el-table-column prop="main_domain" label="主域名" min-width="150" />
|
||||
<el-table-column prop="full_domain" label="完整域名" min-width="200" />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.status === 0" type="warning">审核中</el-tag>
|
||||
<el-tag v-else-if="scope.row.status === 1" type="success">已生效</el-tag>
|
||||
<el-tag v-else type="danger">已禁用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="申请时间" width="180" />
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<template v-if="scope.row.status === 0">
|
||||
<el-button size="small" type="success" @click="handleAudit(scope.row, 'approve')">
|
||||
通过
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click="handleAudit(scope.row, 'reject')">
|
||||
拒绝
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else-if="scope.row.status === 1">
|
||||
<el-button size="small" text type="danger" @click="handleDisable(scope.row)">
|
||||
禁用
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-else style="color: #999;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="fetchData"
|
||||
@current-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getTenantDomainList, auditTenantDomain, toggleTenantDomainStatus } from '@/api/domain'
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const searchForm = reactive({
|
||||
sub_domain: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const tableData = ref<any[]>([])
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTenantDomainList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...searchForm
|
||||
})
|
||||
if (res.code === 200) {
|
||||
tableData.value = res.data.list || []
|
||||
pagination.total = res.data.total || 0
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleAudit = async (row: any, action: string) => {
|
||||
const msg = action === 'approve' ? '确定要通过该域名申请吗?' : '确定要拒绝该域名申请吗?'
|
||||
await ElMessageBox.confirm(msg, '提示', { type: 'warning' })
|
||||
|
||||
const res = await auditTenantDomain({ id: row.id, action })
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg)
|
||||
fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisable = async (row: any) => {
|
||||
await ElMessageBox.confirm('确定要禁用该域名吗?', '提示', { type: 'warning' })
|
||||
|
||||
const res = await toggleTenantDomainStatus(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg)
|
||||
fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +0,0 @@
|
||||
<template></template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -1,287 +0,0 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>主域名池管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加主域名
|
||||
</el-button>
|
||||
<el-button @click="fetchData" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="searchForm.main_domain"
|
||||
placeholder="搜索主域名"
|
||||
clearable
|
||||
style="width: 200px; margin-right: 10px;"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 120px; margin-right: 10px;">
|
||||
<el-option label="禁用" :value="0" />
|
||||
<el-option label="启用" :value="1" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="tableData"
|
||||
style="width: 100%"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="main_domain" label="主域名" min-width="200" />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
|
||||
{{ scope.row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="240" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text @click="handleEdit(scope.row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
<el-button size="small" text @click="handleToggleStatus(scope.row)">
|
||||
<el-icon><Switch /></el-icon>
|
||||
<span>{{ scope.row.status === 1 ? '禁用' : '启用' }}</span>
|
||||
</el-button>
|
||||
<el-button size="small" text type="danger" @click="handleDelete(scope.row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="fetchData"
|
||||
@current-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑主域名' : '添加主域名'"
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||
<el-form-item label="主域名" prop="main_domain">
|
||||
<el-input v-model="form.main_domain" placeholder="请输入主域名,如: example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Edit, Delete, Refresh, Switch } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getDomainPoolList,
|
||||
createDomainPool,
|
||||
updateDomainPool,
|
||||
deleteDomainPool,
|
||||
toggleDomainPoolStatus
|
||||
} from '@/api/domain'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
main_domain: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref<any[]>([])
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
main_domain: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
main_domain: [
|
||||
{ required: true, message: '请输入主域名', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getDomainPoolList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...searchForm
|
||||
})
|
||||
if (res.code === 200) {
|
||||
tableData.value = res.data.list || []
|
||||
pagination.total = res.data.total || 0
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 添加
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
form.id = 0
|
||||
form.main_domain = ''
|
||||
form.status = 1
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row: any) => {
|
||||
isEdit.value = true
|
||||
form.id = row.id
|
||||
form.main_domain = row.main_domain
|
||||
form.status = row.status
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const res = await updateDomainPool(form)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('更新成功')
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '更新失败')
|
||||
}
|
||||
} else {
|
||||
const res = await createDomainPool(form)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('创建成功')
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '创建失败')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = (row: any) => {
|
||||
ElMessageBox.confirm('确定要删除该主域名吗?', '提示', {
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
const res = await deleteDomainPool(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 切换状态
|
||||
const handleToggleStatus = async (row: any) => {
|
||||
const res = await toggleDomainPoolStatus(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('状态更新成功')
|
||||
fetchData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -1,302 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="600px"
|
||||
destroy-on-close
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="链接名称" prop="link_name">
|
||||
<el-input v-model="formData.link_name" placeholder="请输入链接名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="链接地址" prop="link_url">
|
||||
<el-input v-model="formData.link_url" placeholder="请输入链接地址,如:https://www.example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Logo" prop="link_logo">
|
||||
<el-upload
|
||||
class="image-uploader"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleImageSuccess"
|
||||
:on-error="handleImageError"
|
||||
:before-upload="beforeImageUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img
|
||||
v-if="formData.link_logo"
|
||||
:src="getImageUrl(formData.link_logo)"
|
||||
class="image-preview"
|
||||
/>
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="image-uploader-icon"><Plus /></el-icon>
|
||||
<div class="el-upload__text">点击上传Logo</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
建议尺寸:200x200,支持 jpg、png、gif 格式,大小不超过 2MB
|
||||
</div>
|
||||
<el-button
|
||||
v-if="formData.link_logo"
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleRemoveImage"
|
||||
style="margin-top: 8px;"
|
||||
>
|
||||
删除图片
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio :label="1">启用</el-radio>
|
||||
<el-radio :label="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
确定
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { addFriendlink, updateFriendlink } from '@/api/friendlink'
|
||||
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '添加友情链接'
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
rowData: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'success'])
|
||||
|
||||
const visible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
// 上传配置
|
||||
const uploadUrl = ref(API_BASE_URL + "/platform/uploadfiles");
|
||||
const uploadHeaders = ref({
|
||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||
});
|
||||
|
||||
// 图片上传前校验
|
||||
const beforeImageUpload = (file) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error('请上传图片文件!');
|
||||
return false;
|
||||
}
|
||||
if (!isLt2M) {
|
||||
ElMessage.error('图片大小不能超过 2MB!');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 图片上传成功
|
||||
const handleImageSuccess = (response) => {
|
||||
if (response.code === 200 || response.code === 201) {
|
||||
formData.link_logo = response.data.url || response.data.path;
|
||||
ElMessage.success(response.code === 201 ? "文件已存在,直接使用" : "图片上传成功");
|
||||
} else {
|
||||
ElMessage.error(response.msg || "图片上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传失败
|
||||
const handleImageError = () => {
|
||||
ElMessage.error("图片上传失败,请重试");
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = () => {
|
||||
formData.link_logo = "";
|
||||
ElMessage.success("图片已删除");
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于预览)
|
||||
const getImageUrl = (imagePath) => {
|
||||
if (!imagePath) return "";
|
||||
if (imagePath.startsWith("http")) return imagePath;
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
const formData = reactive({
|
||||
link_name: '',
|
||||
link_url: '',
|
||||
link_logo: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
link_name: [
|
||||
{ required: true, message: '请输入链接名称', trigger: 'blur' },
|
||||
{ max: 100, message: '长度不超过100个字符', trigger: 'blur' }
|
||||
],
|
||||
link_url: [
|
||||
{ required: true, message: '请输入链接地址', trigger: 'blur' },
|
||||
{ type: 'url', message: '请输入正确的URL地址', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
resetForm()
|
||||
if (props.isEdit && props.rowData) {
|
||||
Object.assign(formData, props.rowData)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.link_name = ''
|
||||
formData.link_url = ''
|
||||
formData.link_logo = ''
|
||||
formData.description = ''
|
||||
formData.sort = 0
|
||||
formData.status = 1
|
||||
}
|
||||
|
||||
// 关闭
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
let res
|
||||
if (props.isEdit) {
|
||||
res = await updateFriendlink(props.rowData.id, formData)
|
||||
} else {
|
||||
res = await addFriendlink(formData)
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(props.isEdit ? '更新成功' : '添加成功')
|
||||
handleClose()
|
||||
emit('success')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 图片上传样式 */
|
||||
.image-uploader {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.image-uploader:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.image-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.el-upload__text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -1,324 +0,0 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>友情链接管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加链接
|
||||
</el-button>
|
||||
<el-button @click="refresh" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="请输入链接名称搜索"
|
||||
clearable
|
||||
style="width: 200px; margin-right: 10px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="状态筛选"
|
||||
clearable
|
||||
style="width: 120px; margin-right: 10px"
|
||||
>
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table
|
||||
:data="friendlinkList"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
<el-table-column prop="link_name" label="链接名称" min-width="150" align="center" />
|
||||
<el-table-column prop="link_url" label="链接地址" min-width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link :href="row.link_url" target="_blank" type="primary">
|
||||
{{ row.link_url }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="link_logo" label="Logo" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.link_logo"
|
||||
:src="getImageUrl(row.link_logo)"
|
||||
style="width: 50px; height: 50px; object-fit: contain"
|
||||
preview-teleported
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="150" align="center" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
@change="(val) => handleStatusChange(row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.limit"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑组件 -->
|
||||
<EditDialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
:is-edit="isEdit"
|
||||
:row-data="currentRow"
|
||||
@success="handleSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getFriendlinkList,
|
||||
updateFriendlink,
|
||||
deleteFriendlink
|
||||
} from '@/api/friendlink'
|
||||
import EditDialog from './components/edit.vue'
|
||||
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
// 获取图片完整URL
|
||||
const getImageUrl = (imagePath) => {
|
||||
if (!imagePath) return "";
|
||||
if (imagePath.startsWith("http")) return imagePath;
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 数据列表
|
||||
const friendlinkList = ref([])
|
||||
const selectedIds = ref([])
|
||||
|
||||
// 对话框
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const isEdit = ref(false)
|
||||
const currentRow = ref({})
|
||||
|
||||
// 获取列表
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getFriendlinkList({
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
keyword: searchForm.keyword,
|
||||
status: searchForm.status
|
||||
})
|
||||
if (res.code === 200) {
|
||||
friendlinkList.value = res.data.list
|
||||
pagination.total = res.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取友情链接列表失败:', error)
|
||||
ElMessage.error('获取列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = ''
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const refresh = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 分页变化
|
||||
const handleSizeChange = (val) => {
|
||||
pagination.limit = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handlePageChange = (val) => {
|
||||
pagination.page = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 选择变化
|
||||
const handleSelectionChange = (selection) => {
|
||||
selectedIds.value = selection.map(item => item.id)
|
||||
}
|
||||
|
||||
// 添加
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '添加友情链接'
|
||||
currentRow.value = {}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑友情链接'
|
||||
currentRow.value = row
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该友情链接吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
const res = await deleteFriendlink(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 状态变化
|
||||
const handleStatusChange = async (row, val) => {
|
||||
try {
|
||||
const res = await updateFriendlink(row.id, { status: val })
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('状态更新成功')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '状态更新失败')
|
||||
row.status = val === 1 ? 0 : 1
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('状态更新失败:', error)
|
||||
ElMessage.error('状态更新失败')
|
||||
row.status = val === 1 ? 0 : 1
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑成功回调
|
||||
const handleSuccess = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -1,514 +0,0 @@
|
||||
<template>
|
||||
<!-- 添加/编辑菜单对话框 -->
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleDialogClose"
|
||||
>
|
||||
<el-form
|
||||
:model="currentMenu"
|
||||
label-width="100px"
|
||||
:rules="formRules"
|
||||
ref="menuFormRef"
|
||||
>
|
||||
<el-form-item label="父级菜单" prop="pid">
|
||||
<el-tree-select
|
||||
v-model="currentMenu.pid"
|
||||
:data="parentMenuOptions"
|
||||
:props="{ value: 'id', label: 'title', children: 'children' }"
|
||||
placeholder="请选择父级菜单"
|
||||
clearable
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
style="width: 100%"
|
||||
@change="handleParentChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单名称" prop="title">
|
||||
<el-input v-model="currentMenu.title" placeholder="请输入菜单名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单类型" prop="type">
|
||||
<el-radio-group v-model="currentMenu.type" style="width: 100%">
|
||||
<el-radio-button :value="1">目录</el-radio-button>
|
||||
<el-radio-button :value="2">页面</el-radio-button>
|
||||
<el-radio-button :value="3">外链</el-radio-button>
|
||||
<el-radio-button :value="4">单页</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div
|
||||
style="
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
"
|
||||
>
|
||||
<div>
|
||||
• 目录:只有路由地址,用于<span
|
||||
style="color: var(--el-color-primary)"
|
||||
>目录管理</span
|
||||
>和<span style="color: var(--el-color-primary)">菜单分组</span>
|
||||
</div>
|
||||
<div>
|
||||
• 页面:有路由和组件地址,用于<span
|
||||
style="color: var(--el-color-primary)"
|
||||
>页面管理</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
• 外链:无路由和组件,用于<span
|
||||
style="color: var(--el-color-primary)"
|
||||
>外链管理</span
|
||||
>和<span style="color: var(--el-color-primary)">权限控制</span>
|
||||
</div>
|
||||
<div>
|
||||
• 单页:根据路由从单页表获取内容显示,无需填写组件路径
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="路由地址" prop="path" v-if="currentMenu.type !== 3">
|
||||
<el-input v-model="currentMenu.path" placeholder="例如:/system" />
|
||||
<div v-if="currentMenu.type === 4" style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
单页类型:路由需与单页管理中的路由一致,系统会自动从单页表获取内容
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="组件路径"
|
||||
prop="component_path"
|
||||
v-if="currentMenu.type === 2"
|
||||
>
|
||||
<el-input
|
||||
v-model="currentMenu.component_path"
|
||||
placeholder="例如:/apps/knowledge/index.vue"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="外链地址"
|
||||
prop="link_url"
|
||||
v-if="currentMenu.type === 3"
|
||||
>
|
||||
<el-input
|
||||
v-model="currentMenu.link_url"
|
||||
required
|
||||
placeholder="例如:https://www.baidu.com"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="菜单图片" prop="image">
|
||||
<el-upload
|
||||
class="image-uploader"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleImageSuccess"
|
||||
:on-error="handleImageError"
|
||||
:before-upload="beforeImageUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img v-if="currentMenu.image" :src="getImageUrl(currentMenu.image)" class="image-preview" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="image-uploader-icon"><Plus /></el-icon>
|
||||
<div class="el-upload__text">点击上传图片</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
建议尺寸:400x300,支持 jpg、png、gif 格式,大小不超过 2MB
|
||||
</div>
|
||||
<el-button
|
||||
v-if="currentMenu.image"
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleRemoveImage"
|
||||
style="margin-top: 8px;"
|
||||
>
|
||||
删除图片
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="描述" prop="desc">
|
||||
<el-input
|
||||
v-model="currentMenu.desc"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="例如:系统管理"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number
|
||||
v-model="currentMenu.sort"
|
||||
:min="0"
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage, ElForm } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
|
||||
// 定义菜单数据类型
|
||||
interface Menu {
|
||||
id: number;
|
||||
pid: number;
|
||||
title: string;
|
||||
type: number;
|
||||
path: string;
|
||||
component_path: string;
|
||||
link_url?: string;
|
||||
image?: string;
|
||||
sort: number;
|
||||
desc: string;
|
||||
children?: Menu[];
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
menu: Partial<Menu> | null;
|
||||
parentMenuOptions: Menu[];
|
||||
dialogType: "add" | "edit" | "addSub";
|
||||
parentTitle?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
menu: null,
|
||||
parentMenuOptions: () => [],
|
||||
dialogType: "add",
|
||||
parentTitle: "",
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "save", menu: Partial<Menu>): void;
|
||||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
// 表单引用
|
||||
const menuFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
|
||||
// 当前操作的菜单
|
||||
const currentMenu = ref<Partial<Menu>>({
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "",
|
||||
type: 1,
|
||||
path: "",
|
||||
component_path: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
});
|
||||
|
||||
// 上传配置
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
const uploadUrl = ref(API_BASE_URL + '/platform/uploadfiles');
|
||||
const uploadHeaders = ref({
|
||||
'Authorization': 'Bearer ' + (localStorage.getItem('token') || '')
|
||||
});
|
||||
|
||||
// 查找父级菜单路径的递归函数
|
||||
const findMenuPath = (menuList: Menu[], targetId: number): string => {
|
||||
for (const menu of menuList) {
|
||||
if (menu.id === targetId) {
|
||||
return menu.path || "";
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const childPath = findMenuPath(menu.children, targetId);
|
||||
if (childPath) return childPath;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
// 处理父级菜单变化 - 自动填充父级路径到路由地址
|
||||
const handleParentChange = (value: number) => {
|
||||
if (value === 0) {
|
||||
// 选择顶级菜单,清空路径
|
||||
currentMenu.value.path = "";
|
||||
} else {
|
||||
// 选择子菜单,自动填充父级路径
|
||||
const parentPath = findMenuPath(props.parentMenuOptions, value);
|
||||
if (parentPath) {
|
||||
currentMenu.value.path = parentPath;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听props变化,更新当前菜单
|
||||
watch(
|
||||
() => props.menu,
|
||||
(newMenu) => {
|
||||
if (newMenu) {
|
||||
currentMenu.value = {
|
||||
...newMenu,
|
||||
// 确保 pid 有默认值
|
||||
pid: newMenu.pid ?? 0,
|
||||
};
|
||||
} else {
|
||||
// 重置表单
|
||||
currentMenu.value = {
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "",
|
||||
path: "",
|
||||
component_path: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
type: 1,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听props.visible变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && props.dialogType === "add") {
|
||||
// 新增时重置表单
|
||||
currentMenu.value = {
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "",
|
||||
path: "",
|
||||
component_path: "",
|
||||
image: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
type: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 对话框标题
|
||||
const dialogTitle = computed(() => {
|
||||
switch (props.dialogType) {
|
||||
case "add":
|
||||
return "添加菜单";
|
||||
case "edit":
|
||||
return "编辑菜单";
|
||||
case "addSub":
|
||||
return `添加子菜单 (父菜单: ${props.parentTitle || "顶级菜单"})`;
|
||||
default:
|
||||
return "操作菜单";
|
||||
}
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = ref({
|
||||
title: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
|
||||
path: [
|
||||
{
|
||||
required: true,
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (currentMenu.value.type === 3) {
|
||||
// 外链类型不需要路径
|
||||
callback();
|
||||
} else if (!value || value.trim() === "") {
|
||||
callback(new Error("请输入路由地址"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
component_path: [
|
||||
{
|
||||
required: true,
|
||||
validator: (rule: any, value: any, callback: any) => {
|
||||
if (currentMenu.value.type === 2) {
|
||||
// 页面类型需要组件路径
|
||||
if (!value || value.trim() === "") {
|
||||
callback(new Error("请输入组件路径"));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
// 其他类型(目录、外链、单页)不需要组件路径
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
||||
});
|
||||
|
||||
|
||||
// 监听菜单类型变化,自动清空不相关的字段
|
||||
watch(
|
||||
() => currentMenu.value.type,
|
||||
(newType, oldType) => {
|
||||
if (newType === oldType) return; // 避免初始化时的触发
|
||||
|
||||
if (newType === 1) {
|
||||
// 目录:清空组件路径,保留路径
|
||||
currentMenu.value.component_path = "";
|
||||
} else if (newType === 2) {
|
||||
// 页面:保留路径和组件路径
|
||||
// 不清空,保持现有值
|
||||
} else if (newType === 3) {
|
||||
// 外链:清空路径和组件路径
|
||||
currentMenu.value.path = "";
|
||||
currentMenu.value.component_path = "";
|
||||
} else if (newType === 4) {
|
||||
// 单页:清空组件路径,保留路径(路径用于匹配单页表)
|
||||
currentMenu.value.component_path = "";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
};
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = (value: boolean) => {
|
||||
if (!value) {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传前校验
|
||||
const beforeImageUpload = (file: any) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!');
|
||||
return false;
|
||||
}
|
||||
if (!isLt2M) {
|
||||
ElMessage.error('图片大小不能超过 2MB!');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 图片上传成功
|
||||
const handleImageSuccess = (response: any) => {
|
||||
if (response.code === 200) {
|
||||
// 直接保存相对路径,不拼接API_BASE_URL
|
||||
currentMenu.value.image = response.data.url || response.data.path;
|
||||
ElMessage.success('图片上传成功');
|
||||
} else {
|
||||
ElMessage.error(response.msg || '图片上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传失败
|
||||
const handleImageError = () => {
|
||||
ElMessage.error('图片上传失败,请重试');
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = () => {
|
||||
currentMenu.value.image = '';
|
||||
ElMessage.success('图片已删除');
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于预览)
|
||||
const getImageUrl = (imagePath: string) => {
|
||||
if (!imagePath) return '';
|
||||
// 如果是绝对路径,直接返回
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
// 如果是相对路径,拼接API基础地址
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 保存菜单
|
||||
const handleSave = async () => {
|
||||
// 表单验证
|
||||
if (!menuFormRef.value) return;
|
||||
const valid = await menuFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 解决后端时间字段问题:过滤掉不需要的字段
|
||||
const payload = { ...currentMenu.value };
|
||||
|
||||
// 触发保存事件
|
||||
emit("save", payload);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 图片上传样式 */
|
||||
.image-uploader {
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 178px;
|
||||
height: 178px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 178px;
|
||||
height: 178px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.image-uploader-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.el-upload__text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,563 +0,0 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>前端导航管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="expandAll">
|
||||
<el-icon>
|
||||
<FolderOpened />
|
||||
</el-icon>
|
||||
全部展开
|
||||
</el-button>
|
||||
<el-button @click="collapseAll">
|
||||
<el-icon>
|
||||
<Folder />
|
||||
</el-icon>
|
||||
全部折叠
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAddMenu">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加菜单
|
||||
</el-button>
|
||||
<el-button @click="refresh" :loading="loading">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 树形表格 -->
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="menuTree"
|
||||
style="width: 100%"
|
||||
row-key="id"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
:tree-props="{
|
||||
children: 'children',
|
||||
hasChildren: 'hasChildren',
|
||||
}"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<el-table-column prop="title" label="菜单名称" width="200">
|
||||
<template #default="scope">
|
||||
<div class="menu-item">
|
||||
<i
|
||||
v-if="scope.row.icon"
|
||||
:class="scope.row.icon"
|
||||
class="menu-icon"
|
||||
></i>
|
||||
<span>{{ scope.row.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="path" label="路由地址"></el-table-column>
|
||||
|
||||
<el-table-column label="图片" width="200" align="center">
|
||||
<template #default="scope">
|
||||
<el-image
|
||||
v-if="scope.row.image"
|
||||
:src="getImageUrl(scope.row.image)"
|
||||
:preview-src-list="[getImageUrl(scope.row.image)]"
|
||||
:preview-teleported="true"
|
||||
fit="cover"
|
||||
style="width: 50px; height: 50px; border-radius: 4px;"
|
||||
/>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="MenuType"
|
||||
label="菜单类型"
|
||||
width="120"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="getMenuTypeTagType(scope.row.type)">
|
||||
{{ getMenuTypeTitle(scope.row.type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="sort"
|
||||
label="排序"
|
||||
width="80"
|
||||
align="center"
|
||||
></el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="280" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
@click="handleAddSubMenu(scope.row)"
|
||||
:disabled="scope.row.type === 3"
|
||||
>
|
||||
<el-icon>
|
||||
<CirclePlus />
|
||||
</el-icon>
|
||||
<span>子菜单</span>
|
||||
</el-button>
|
||||
|
||||
<el-button size="small" text @click="handleEditMenu(scope.row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="danger"
|
||||
@click="handleDeleteMenu(scope.row)"
|
||||
>
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 引入编辑组件 -->
|
||||
<MenuEdit
|
||||
v-model:visible="dialogVisible"
|
||||
:menu="dialogMenu"
|
||||
:parent-menu-options="parentMenuOptions"
|
||||
:dialog-type="dialogType"
|
||||
:parent-title="dialogParentTitle"
|
||||
@save="handleMenuSave"
|
||||
@cancel="handleMenuCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox, ElForm } from "element-plus";
|
||||
import {
|
||||
Plus,
|
||||
CirclePlus,
|
||||
Edit,
|
||||
Delete,
|
||||
Refresh,
|
||||
FolderOpened,
|
||||
Folder,
|
||||
} from "@element-plus/icons-vue";
|
||||
import {
|
||||
getFrontMenus,
|
||||
createFrontMenu,
|
||||
editFrontMenu,
|
||||
deleteFrontMenu,
|
||||
} from "@/api/frontMenu";
|
||||
import MenuEdit from "./components/edit.vue";
|
||||
|
||||
// 定义菜单数据类型
|
||||
interface Menu {
|
||||
id: number;
|
||||
pid: number;
|
||||
title: string;
|
||||
type: number;
|
||||
path: string;
|
||||
component_path: string;
|
||||
image?: string;
|
||||
sort: number;
|
||||
desc: string;
|
||||
children?: Menu[];
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
// 菜单树形数据
|
||||
const menuTree = ref<Menu[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 表格引用
|
||||
const tableRef = ref<any>(null);
|
||||
|
||||
// 对话框相关变量
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMenu = ref<Partial<Menu> | null>(null);
|
||||
const dialogType = ref<"add" | "edit" | "addSub">("add");
|
||||
const dialogParentTitle = ref("");
|
||||
|
||||
// 父级菜单选项
|
||||
const parentMenuOptions = ref<Menu[]>([]);
|
||||
|
||||
let fetchMenusPromise: Promise<any> | null = null;
|
||||
|
||||
const fetchMenus = async () => {
|
||||
if (fetchMenusPromise) {
|
||||
return fetchMenusPromise;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
fetchMenusPromise = (async () => {
|
||||
try {
|
||||
const result = await getFrontMenus();
|
||||
if (result.code === 200) {
|
||||
menuTree.value = result.data;
|
||||
parentMenuOptions.value = [
|
||||
{
|
||||
id: 0,
|
||||
pid: -1,
|
||||
title: "顶级菜单",
|
||||
type: 1,
|
||||
path: "",
|
||||
component_path: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
children: result.data,
|
||||
} as Menu,
|
||||
];
|
||||
} else {
|
||||
ElMessage.error("获取前端导航失败: " + result.msg);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取前端导航数据失败: " + (error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
fetchMenusPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return fetchMenusPromise;
|
||||
};
|
||||
|
||||
// 刷新界面
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await fetchMenus();
|
||||
ElMessage.success("刷新成功");
|
||||
} catch (error) {
|
||||
ElMessage.error("刷新失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有前端导航行数据(包括子节点)
|
||||
function getAllMenuRows(menuList: Menu[]): Menu[] {
|
||||
const rows: Menu[] = [];
|
||||
menuList.forEach((frontMenu) => {
|
||||
rows.push(frontMenu);
|
||||
if (frontMenu.children && frontMenu.children.length > 0) {
|
||||
rows.push(...getAllMenuRows(frontMenu.children));
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// 全部展开
|
||||
function expandAll() {
|
||||
if (!tableRef.value) return;
|
||||
const allRows = getAllMenuRows(menuTree.value);
|
||||
allRows.forEach((row) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
tableRef.value.toggleRowExpansion(row, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 全部折叠
|
||||
function collapseAll() {
|
||||
if (!tableRef.value) return;
|
||||
const allRows = getAllMenuRows(menuTree.value);
|
||||
allRows.forEach((row) => {
|
||||
if (row.children && row.children.length > 0) {
|
||||
tableRef.value.toggleRowExpansion(row, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理行点击事件 - 展开/收缩
|
||||
function handleRowClick(row: Menu, column: any, event: Event) {
|
||||
// 如果点击的是操作列,不触发展开/收缩
|
||||
if (column && column.label === '操作') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果该行有子菜单,切换展开/收缩状态
|
||||
if (row.children && row.children.length > 0) {
|
||||
if (tableRef.value) {
|
||||
tableRef.value.toggleRowExpansion(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建菜单树(处理父子关系)
|
||||
const buildMenuTree = (menuList: Menu[]): Menu[] => {
|
||||
return menuList;
|
||||
};
|
||||
|
||||
// 获取菜单类型名称
|
||||
const getMenuTypeTitle = (type: number) => {
|
||||
const typeMap = { 1: "目录", 2: "页面", 3: "外链", 4: "单页" };
|
||||
return typeMap[type as keyof typeof typeMap] || "未知类型";
|
||||
};
|
||||
|
||||
// 获取菜单类型标签样式
|
||||
const getMenuTypeTagType = (type: number) => {
|
||||
const typeMap = { 1: "primary", 2: "success", 3: "info", 4: "warning" };
|
||||
return typeMap[type as keyof typeof typeMap] || "default";
|
||||
};
|
||||
|
||||
// 添加子菜单
|
||||
const handleAddSubMenu = (parentMenu: Menu) => {
|
||||
dialogType.value = "addSub";
|
||||
dialogParentTitle.value = parentMenu.title;
|
||||
dialogMenu.value = {
|
||||
id: 0, // 明确设置为 0,表示是新增
|
||||
pid: parentMenu.id,
|
||||
title: "",
|
||||
path: parentMenu.path || "", // 自动填充父级路径
|
||||
component_path: "",
|
||||
desc: "",
|
||||
sort: 0,
|
||||
type: parentMenu.type === 1 ? 2 : parentMenu.type, // 如果父菜单是目录,子菜单默认为页面
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑菜单
|
||||
const handleEditMenu = (menu: Menu) => {
|
||||
dialogType.value = "edit";
|
||||
dialogMenu.value = { ...menu };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除菜单
|
||||
const handleDeleteMenu = (menu: Menu) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除菜单 "${menu.title}" 吗?${
|
||||
menu.hasChildren ? "其下所有子菜单也将被删除。" : ""
|
||||
}`,
|
||||
"确认删除",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const result = await deleteFrontMenu(menu.id);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchMenus();
|
||||
} else {
|
||||
ElMessage.error("删除失败: " + result.msg);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("删除失败: " + (error as Error).message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 添加菜单
|
||||
const handleAddMenu = () => {
|
||||
dialogType.value = "add";
|
||||
dialogMenu.value = {
|
||||
id: 0,
|
||||
pid: 0,
|
||||
title: "",
|
||||
type: 1,
|
||||
path: "",
|
||||
component_path: "",
|
||||
sort: 0,
|
||||
desc: "",
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 处理菜单保存
|
||||
const handleMenuSave = async (menu: Partial<Menu>) => {
|
||||
try {
|
||||
// 解决后端时间字段问题:过滤掉不需要的字段
|
||||
const payload = { ...menu };
|
||||
|
||||
// 确保 pid 是整数类型(后端要求必须是整数)
|
||||
// 处理数组情况:如果 pid 是数组,取第一个元素
|
||||
let pidValue: any = payload.pid;
|
||||
if (Array.isArray(pidValue)) {
|
||||
pidValue = pidValue.length > 0 ? pidValue[0] : null;
|
||||
}
|
||||
|
||||
// 强制转换为整数
|
||||
if (pidValue === null || pidValue === undefined || pidValue === '') {
|
||||
payload.pid = 0;
|
||||
} else {
|
||||
const parsedPid = parseInt(String(pidValue), 10);
|
||||
if (isNaN(parsedPid)) {
|
||||
payload.pid = 0;
|
||||
} else {
|
||||
payload.pid = parsedPid;
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是新增还是编辑:没有 id 或 id 为 0 或 dialogType 为 add/addSub 时为新增
|
||||
if (!menu.id || menu.id === 0 || dialogType.value === 'add' || dialogType.value === 'addSub') {
|
||||
// 新增菜单(包括添加顶级菜单和添加子菜单)
|
||||
const result = await createFrontMenu(payload as Menu);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "菜单添加成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchMenus();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "添加失败");
|
||||
}
|
||||
} else {
|
||||
// 编辑已存在的菜单
|
||||
const result = await editFrontMenu(menu.id!, payload as Menu);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "更新成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchMenus();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "更新失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("操作失败: " + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理菜单取消
|
||||
const handleMenuCancel = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于显示)
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
const getImageUrl = (imagePath: string) => {
|
||||
if (!imagePath) return '';
|
||||
// 如果是绝对路径,直接返回
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
// 如果是相对路径,拼接API基础地址
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 组件挂载时加载菜单
|
||||
onMounted(() => {
|
||||
fetchMenus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
}
|
||||
|
||||
.card-header span {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 表格核心样式 */
|
||||
:deep(.el-table) {
|
||||
border-radius: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body td) {
|
||||
padding: 12px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 有子菜单的行显示手型光标 */
|
||||
:deep(.el-table__body tr.el-table__row--level-0) {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table__body tr) {
|
||||
&:has(.el-table__expand-icon:not(.el-table__expand-icon--hidden)) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* 展开图标与菜单内容对齐 */
|
||||
:deep(.el-table__expand-icon) {
|
||||
margin: 0 !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-icon-cell) {
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
|
||||
/* 菜单项样式 */
|
||||
.menu-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 隐藏无子女菜单的展开图标 */
|
||||
:deep(.el-table__expand-icon--hidden) {
|
||||
visibility: hidden;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-icon) {
|
||||
margin-right: 8px !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -1,245 +0,0 @@
|
||||
<template>
|
||||
<!-- 添加/编辑单页对话框 -->
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="80%"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleDialogClose"
|
||||
>
|
||||
<el-form
|
||||
:model="currentOnePage"
|
||||
label-width="100px"
|
||||
:rules="formRules"
|
||||
ref="onePageFormRef"
|
||||
>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input
|
||||
v-model="currentOnePage.title"
|
||||
placeholder="请输入单页标题"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="路由" prop="path">
|
||||
<el-input
|
||||
v-model="currentOnePage.path"
|
||||
placeholder="例如:/about、/contact、/privacy"
|
||||
maxlength="200"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
路由地址,必须以 / 开头,例如:/about
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input
|
||||
v-model="currentOnePage.content"
|
||||
type="textarea"
|
||||
:rows="20"
|
||||
placeholder="请输入单页内容(支持代码)"
|
||||
style="font-family: 'Courier New', monospace;"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
支持代码和文本内容,使用等宽字体显示
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number
|
||||
v-model="currentOnePage.sort"
|
||||
:min="0"
|
||||
placeholder="数字越小越靠前"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
数字越小,排序越靠前
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch
|
||||
v-model="currentOnePage.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
/>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
启用后,前端可以访问该单页
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage, ElForm } from "element-plus";
|
||||
|
||||
// 定义单页数据类型
|
||||
interface OnePage {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
path: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
create_time?: string;
|
||||
update_time?: string;
|
||||
delete_time?: string;
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onepage: Partial<OnePage> | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
onepage: null,
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: "update:visible", value: boolean): void;
|
||||
(e: "save", onepage: Partial<OnePage>): void;
|
||||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
// 表单引用
|
||||
const onePageFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
|
||||
// 当前操作的单页
|
||||
const currentOnePage = ref<Partial<OnePage>>({
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
path: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
// 对话框标题
|
||||
const dialogTitle = computed(() => {
|
||||
return props.onepage?.id && props.onepage.id > 0 ? "编辑单页" : "添加单页";
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = ref({
|
||||
title: [{ required: true, message: "请输入单页标题", trigger: "blur" }],
|
||||
path: [
|
||||
{ required: true, message: "请输入路由", trigger: "blur" },
|
||||
{
|
||||
pattern: /^\/[a-zA-Z0-9\/_-]*$/,
|
||||
message: "路由必须以 / 开头,只能包含字母、数字、下划线、横线和斜线",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
content: [{ required: true, message: "请输入单页内容", trigger: "blur" }],
|
||||
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
||||
});
|
||||
|
||||
// 监听props变化,更新当前单页
|
||||
watch(
|
||||
() => props.onepage,
|
||||
(newOnePage) => {
|
||||
if (newOnePage) {
|
||||
currentOnePage.value = {
|
||||
...newOnePage,
|
||||
};
|
||||
} else {
|
||||
// 重置表单
|
||||
currentOnePage.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
path: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 监听props.visible变化
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && (!props.onepage || !props.onepage.id)) {
|
||||
// 新增时重置表单
|
||||
currentOnePage.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
path: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
};
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = (value: boolean) => {
|
||||
if (!value) {
|
||||
emit("update:visible", false);
|
||||
emit("cancel");
|
||||
}
|
||||
};
|
||||
|
||||
// 保存单页
|
||||
const handleSave = async () => {
|
||||
// 表单验证
|
||||
if (!onePageFormRef.value) return;
|
||||
const valid = await onePageFormRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
// 准备提交数据
|
||||
const payload = { ...currentOnePage.value };
|
||||
|
||||
// 触发保存事件
|
||||
emit("save", payload);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 对话框样式精简 */
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 代码编辑器样式 */
|
||||
:deep(.el-textarea__inner) {
|
||||
font-family: 'Courier New', 'Consolas', 'Monaco', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>单页管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAddOnePage">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加单页
|
||||
</el-button>
|
||||
<el-button @click="refresh" :loading="loading">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="onePageList"
|
||||
style="width: 100%"
|
||||
border
|
||||
v-loading="loading"
|
||||
element-loading-text="正在加载..."
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
|
||||
<el-table-column prop="title" label="标题" min-width="150" />
|
||||
|
||||
<el-table-column prop="path" label="路由" width="200">
|
||||
<template #default="scope">
|
||||
<el-tag type="info">{{ scope.row.path }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div style="font-family: 'Courier New', monospace; white-space: pre-wrap;">{{ getContentPreview(scope.row.content) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="status"
|
||||
label="状态"
|
||||
width="100"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">
|
||||
{{ scope.row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
prop="sort"
|
||||
label="排序"
|
||||
width="100"
|
||||
align="center"
|
||||
sortable
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag>{{ scope.row.sort || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text @click="handleEditOnePage(scope.row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
<span>编辑</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="danger"
|
||||
@click="handleDeleteOnePage(scope.row)"
|
||||
>
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
<span>删除</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 引入编辑组件 -->
|
||||
<OnePageEdit
|
||||
v-model:visible="dialogVisible"
|
||||
:onepage="dialogOnePage"
|
||||
@save="handleOnePageSave"
|
||||
@cancel="handleOnePageCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Edit, Delete, Refresh } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getOnePages,
|
||||
createOnePage,
|
||||
editOnePage,
|
||||
deleteOnePage,
|
||||
} from "@/api/onepage";
|
||||
import OnePageEdit from "./components/edit.vue";
|
||||
|
||||
// 定义单页数据类型
|
||||
interface OnePage {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
path: string;
|
||||
sort: number;
|
||||
status: 0 | 1;
|
||||
create_time?: string;
|
||||
update_time?: string;
|
||||
delete_time?: string;
|
||||
}
|
||||
|
||||
// 单页列表
|
||||
const onePageList = ref<OnePage[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 对话框相关变量
|
||||
const dialogVisible = ref(false);
|
||||
const dialogOnePage = ref<Partial<OnePage> | null>(null);
|
||||
|
||||
// 获取单页列表
|
||||
const fetchOnePages = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await getOnePages();
|
||||
if (result.code === 200) {
|
||||
onePageList.value = result.data || [];
|
||||
} else {
|
||||
ElMessage.error("获取单页列表失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("获取单页列表失败: " + (error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新界面
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await fetchOnePages();
|
||||
ElMessage.success("刷新成功");
|
||||
} catch (error) {
|
||||
ElMessage.error("刷新失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取内容预览
|
||||
const getContentPreview = (content: string) => {
|
||||
if (!content) return '-';
|
||||
// 限制长度,保留原始格式
|
||||
return content.length > 100 ? content.substring(0, 100) + '...' : content;
|
||||
};
|
||||
|
||||
// 添加单页
|
||||
const handleAddOnePage = () => {
|
||||
dialogOnePage.value = {
|
||||
id: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
path: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑单页
|
||||
const handleEditOnePage = (onePage: OnePage) => {
|
||||
dialogOnePage.value = { ...onePage };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除单页
|
||||
const handleDeleteOnePage = (onePage: OnePage) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除单页 "${onePage.title}" 吗?`,
|
||||
"确认删除",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
const result = await deleteOnePage(onePage.id);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success("删除成功");
|
||||
fetchOnePages();
|
||||
} else {
|
||||
ElMessage.error("删除失败: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("删除失败: " + (error as Error).message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理单页保存
|
||||
const handleOnePageSave = async (onePage: Partial<OnePage>) => {
|
||||
try {
|
||||
const payload = { ...onePage };
|
||||
|
||||
// 判断是新增还是编辑
|
||||
if (!onePage.id || onePage.id === 0) {
|
||||
// 新增单页
|
||||
const result = await createOnePage(payload as OnePage);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "单页添加成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchOnePages();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "添加失败");
|
||||
}
|
||||
} else {
|
||||
// 编辑已存在的单页
|
||||
const result = await editOnePage(onePage.id!, payload as OnePage);
|
||||
if (result.code === 200) {
|
||||
ElMessage.success(result.msg || "更新成功");
|
||||
dialogVisible.value = false;
|
||||
await fetchOnePages();
|
||||
} else {
|
||||
ElMessage.error(result.msg || "更新失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("操作失败: " + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理单页取消
|
||||
const handleOnePageCancel = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
// 组件挂载时加载单页列表
|
||||
onMounted(() => {
|
||||
fetchOnePages();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container-box {
|
||||
padding: 24px;
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
:deep(.el-table) {
|
||||
border-radius: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body td) {
|
||||
padding: 12px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="600px"
|
||||
destroy-on-close
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="产品名称" prop="title">
|
||||
<el-input v-model="formData.title" placeholder="请输入产品名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品描述" prop="desc">
|
||||
<el-input
|
||||
v-model="formData.desc"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="请输入产品描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品内容" prop="content">
|
||||
<div class="editor-container">
|
||||
<WangEditor v-model="formData.content" style="height: 450px;" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品图片" prop="thumb">
|
||||
<div class="flex-direction">
|
||||
<el-upload
|
||||
class="image-uploader"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleImageSuccess"
|
||||
:on-error="handleImageError"
|
||||
:before-upload="beforeImageUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img
|
||||
v-if="formData.thumb"
|
||||
:src="getImageUrl(formData.thumb)"
|
||||
class="image-preview"
|
||||
/>
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="image-uploader-icon"><Plus /></el-icon>
|
||||
<div class="el-upload__text">点击上传产品图片</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div
|
||||
style="
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
"
|
||||
>
|
||||
建议尺寸:200x200,支持 jpg、png、gif 格式,大小不超过 2MB
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="formData.thumb"
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleRemoveImage"
|
||||
style="margin-top: 8px"
|
||||
>
|
||||
删除产品图片
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:loading="submitLoading"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { addProducts, updateProducts } from "@/api/products";
|
||||
import WangEditor from "@/views/components/WangEditor.vue";
|
||||
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "添加产品",
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rowData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "success"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const formRef = ref(null);
|
||||
|
||||
// 上传配置
|
||||
const uploadUrl = ref(API_BASE_URL + "/platform/uploadfiles");
|
||||
const uploadHeaders = ref({
|
||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||
});
|
||||
|
||||
// 图片上传前校验
|
||||
const beforeImageUpload = (file) => {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error("请上传图片文件!");
|
||||
return false;
|
||||
}
|
||||
if (!isLt2M) {
|
||||
ElMessage.error("图片大小不能超过 2MB!");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 图片上传成功
|
||||
const handleImageSuccess = (response) => {
|
||||
if (response.code === 200 || response.code === 201) {
|
||||
formData.thumb = response.data.url || response.data.path;
|
||||
ElMessage.success(
|
||||
response.code === 201 ? "文件已存在,直接使用" : "图片上传成功",
|
||||
);
|
||||
} else {
|
||||
ElMessage.error(response.msg || "图片上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传失败
|
||||
const handleImageError = () => {
|
||||
ElMessage.error("图片上传失败,请重试");
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = () => {
|
||||
formData.thumb = "";
|
||||
ElMessage.success("图片已删除");
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于预览)
|
||||
const getImageUrl = (imagePath) => {
|
||||
if (!imagePath) return "";
|
||||
if (imagePath.startsWith("http")) return imagePath;
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
const formData = reactive({
|
||||
title: "",
|
||||
desc: "",
|
||||
thumb: "",
|
||||
url: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
title: [
|
||||
{ required: true, message: "请输入产品名称", trigger: "blur" },
|
||||
{ max: 100, message: "长度不超过100个字符", trigger: "blur" },
|
||||
],
|
||||
desc: [
|
||||
{ required: true, message: "请输入产品描述", trigger: "blur" },
|
||||
{ max: 200, message: "描述长度不超过200个字符", trigger: "blur" },
|
||||
],
|
||||
};
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
resetForm();
|
||||
if (props.isEdit && props.rowData) {
|
||||
Object.assign(formData, props.rowData);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (val) => {
|
||||
emit("update:modelValue", val);
|
||||
});
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.title = "";
|
||||
formData.desc = "";
|
||||
formData.thumb = "";
|
||||
formData.url = "";
|
||||
formData.sort = 0;
|
||||
formData.status = 1;
|
||||
};
|
||||
|
||||
// 关闭
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
let res;
|
||||
if (props.isEdit) {
|
||||
res = await updateProducts(props.rowData.id, formData);
|
||||
} else {
|
||||
res = await addProducts(formData);
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(props.isEdit ? "更新成功" : "添加成功");
|
||||
handleClose();
|
||||
emit("success");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "操作失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("提交失败:", error);
|
||||
ElMessage.error("操作失败");
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 图片上传样式 */
|
||||
.image-uploader {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.image-uploader:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.image-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.el-upload__text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.flex-direction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
</style>
|
||||
@@ -1,295 +0,0 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>企业产品管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleTypes">
|
||||
<i class="fa-solid fa-layer-group"></i>
|
||||
分类管理
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
添加产品
|
||||
</el-button>
|
||||
<el-button @click="refresh" :loading="loading">
|
||||
<i class="fa-solid fa-refresh"></i>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchForm.keyword" placeholder="请输入产品名称搜索" clearable style="width: 200px; margin-right: 10px"
|
||||
@keyup.enter="handleSearch" />
|
||||
<el-select v-model="searchForm.status" placeholder="状态筛选" clearable style="width: 120px; margin-right: 10px">
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon>
|
||||
<Search />
|
||||
</el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="productsList" style="width: 100%" v-loading="loading" @selection-change="handleSelectionChange">
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="thumb" label="产品图片" min-width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.thumb" :src="getImageUrl(row.thumb)" :preview-src-list="[getImageUrl(row.thumb)]"
|
||||
fit="cover" class="product-thumb" :preview-teleported="true" lazy>
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<el-icon>
|
||||
<Picture />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
|
||||
<span v-else class="text-gray">暂无图片</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="产品名称" min-width="150" align="center" />
|
||||
<el-table-column prop="desc" label="产品描述" min-width="150" align="center" />
|
||||
<el-table-column prop="url" label="跳转地址" min-width="150" align="center" />
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit"
|
||||
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange" @current-change="handlePageChange" />
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑组件 -->
|
||||
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
|
||||
@success="handleSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
getProductsList,
|
||||
updateProducts,
|
||||
deleteProducts
|
||||
} from '@/api/products'
|
||||
import EditDialog from './components/edit.vue'
|
||||
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 获取图片完整URL
|
||||
// 接口可能返回:
|
||||
// 1) 绝对地址:http(s)://...
|
||||
// 2) 以 / 开头的相对路径:/storage/uploads/xxx.png
|
||||
// 3) 不以 / 开头的相对路径:storage/uploads/xxx.png
|
||||
const getImageUrl = (imagePath) => {
|
||||
if (!imagePath) return "";
|
||||
if (typeof imagePath !== 'string') imagePath = String(imagePath);
|
||||
if (imagePath.startsWith("http")) return imagePath;
|
||||
|
||||
const base = (API_BASE_URL ?? '').replace(/\/$/, '');
|
||||
const path = imagePath.startsWith('/') ? imagePath : `/${imagePath}`;
|
||||
return base + path;
|
||||
};
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 数据列表
|
||||
const productsList = ref([])
|
||||
const selectedIds = ref([])
|
||||
|
||||
// 对话框
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const isEdit = ref(false)
|
||||
const currentRow = ref({})
|
||||
|
||||
// 获取列表
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getProductsList({
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
keyword: searchForm.keyword,
|
||||
status: searchForm.status
|
||||
})
|
||||
if (res.code === 200) {
|
||||
productsList.value = res.data.list
|
||||
pagination.total = res.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取企业产品列表失败:', error)
|
||||
ElMessage.error('获取企业产品列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = ''
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const refresh = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 分页变化
|
||||
const handleSizeChange = (val) => {
|
||||
pagination.limit = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handlePageChange = (val) => {
|
||||
pagination.page = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 选择变化
|
||||
const handleSelectionChange = (selection) => {
|
||||
selectedIds.value = selection.map(item => item.id)
|
||||
}
|
||||
|
||||
// 添加
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '添加企业产品'
|
||||
currentRow.value = {}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑企业产品'
|
||||
currentRow.value = row
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该企业产品吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
const res = await deleteProducts(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除企业产品失败:', error)
|
||||
ElMessage.error('删除企业产品失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑成功回调
|
||||
const handleSuccess = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 跳转到产品分类管理页
|
||||
const handleTypes = () => {
|
||||
router.push('/apps/cms/products/types')
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -1,211 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="600px"
|
||||
destroy-on-close
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="分类名称" prop="title">
|
||||
<el-input v-model="formData.title" placeholder="请输入分类名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="父级分类" prop="pid">
|
||||
<el-select
|
||||
v-model="formData.pid"
|
||||
placeholder="请选择父级分类"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="顶级分类" :value="0" />
|
||||
<el-option
|
||||
v-for="item in typeOptions"
|
||||
:key="item.id"
|
||||
:label="item.title"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="分类描述" prop="desc">
|
||||
<el-input
|
||||
v-model="formData.desc"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入分类描述(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:loading="submitLoading"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
addProductsTypes,
|
||||
updateProductsTypes,
|
||||
getProductsTypesList
|
||||
} from '@/api/products'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '添加分类'
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
rowData: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'success'])
|
||||
|
||||
const visible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const typeOptions = ref([])
|
||||
|
||||
const formData = reactive({
|
||||
title: '',
|
||||
pid: 0,
|
||||
desc: '',
|
||||
sort: 0
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
title: [
|
||||
{ required: true, message: '请输入分类名称', trigger: 'blur' },
|
||||
{ max: 100, message: '长度不超过100个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formData.title = ''
|
||||
formData.pid = 0
|
||||
formData.desc = ''
|
||||
formData.sort = 0
|
||||
}
|
||||
|
||||
const loadTypeOptions = async () => {
|
||||
try {
|
||||
const res = await getProductsTypesList({ page: 1, limit: 1000, keyword: '' })
|
||||
if (res.code === 200 && Array.isArray(res.data?.list)) {
|
||||
const curId = props.isEdit ? Number(props.rowData?.id ?? 0) : 0
|
||||
typeOptions.value = curId
|
||||
? res.data.list.filter((item) => Number(item.id) !== curId)
|
||||
: res.data.list
|
||||
} else {
|
||||
typeOptions.value = []
|
||||
}
|
||||
} catch (e) {
|
||||
// 下拉选项失败不影响弹窗可用性
|
||||
console.error('加载父级分类失败:', e)
|
||||
typeOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 打开/关闭
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
resetForm()
|
||||
if (props.isEdit && props.rowData) {
|
||||
Object.assign(formData, props.rowData)
|
||||
formData.pid = Number(props.rowData?.pid ?? 0)
|
||||
formData.sort = Number(props.rowData?.sort ?? 0)
|
||||
}
|
||||
await loadTypeOptions()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
let res
|
||||
const payload = {
|
||||
title: formData.title,
|
||||
pid: Number(formData.pid ?? 0),
|
||||
desc: formData.desc ?? '',
|
||||
sort: Number(formData.sort ?? 0)
|
||||
}
|
||||
|
||||
if (props.isEdit) {
|
||||
res = await updateProductsTypes(Number(props.rowData?.id), payload)
|
||||
} else {
|
||||
res = await addProductsTypes(payload)
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(props.isEdit ? '更新分类成功' : '添加分类成功')
|
||||
handleClose()
|
||||
emit('success')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('提交分类失败:', e)
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>企业产品分类管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
添加分类
|
||||
</el-button>
|
||||
<el-button @click="refresh" :loading="loading">
|
||||
<i class="fa-solid fa-refresh"></i>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="请输入分类名称搜索"
|
||||
clearable
|
||||
style="width: 200px; margin-right: 10px"
|
||||
@keyup.enter="handleSearch" />
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon>
|
||||
<Search />
|
||||
</el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="typesList" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="title" label="分类名称" min-width="180" align="center" />
|
||||
<el-table-column prop="desc" label="分类描述" min-width="200" align="center" />
|
||||
<el-table-column prop="pid" label="父级ID" width="110" align="center" />
|
||||
<el-table-column prop="sort" label="排序" width="90" align="center" />
|
||||
<el-table-column prop="create_time" label="创建时间" min-width="180" align="center" />
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit"
|
||||
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange" @current-change="handlePageChange" />
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑组件 -->
|
||||
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
|
||||
@success="handleSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getProductsTypesList,
|
||||
deleteProductsTypes
|
||||
} from '@/api/products'
|
||||
import EditDialog from './components/edit.vue'
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
keyword: ''
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 数据列表
|
||||
const typesList = ref([])
|
||||
|
||||
// 对话框
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const isEdit = ref(false)
|
||||
const currentRow = ref({})
|
||||
|
||||
// 获取列表
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getProductsTypesList({
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
keyword: searchForm.keyword
|
||||
})
|
||||
if (res.code === 200) {
|
||||
typesList.value = res.data.list
|
||||
pagination.total = res.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取企业产品分类列表失败:', error)
|
||||
ElMessage.error('获取企业产品分类列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const refresh = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 分页变化
|
||||
const handleSizeChange = (val) => {
|
||||
pagination.limit = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handlePageChange = (val) => {
|
||||
pagination.page = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 添加
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '添加企业产品分类'
|
||||
currentRow.value = {}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑企业产品分类'
|
||||
currentRow.value = row
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该企业产品分类吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
const res = await deleteProductsTypes(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除分类成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除企业产品分类失败:', error)
|
||||
ElMessage.error('删除企业产品分类失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑成功回调
|
||||
const handleSuccess = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>这是cate</template>
|
||||
<script lang="ts" setup></script>
|
||||
<style lang="less" scoped></style>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>这是list</template>
|
||||
<script lang="ts" setup></script>
|
||||
<style lang="less" scoped></style>
|
||||
@@ -1,315 +0,0 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="600px"
|
||||
destroy-on-close
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="服务名称" prop="title">
|
||||
<el-input v-model="formData.title" placeholder="请输入服务名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务描述" prop="desc">
|
||||
<el-input
|
||||
v-model="formData.desc"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="请输入服务描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Logo" prop="thumb">
|
||||
<div class="flex-direction">
|
||||
<el-upload
|
||||
class="image-uploader"
|
||||
:action="uploadUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleImageSuccess"
|
||||
:on-error="handleImageError"
|
||||
:before-upload="beforeImageUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img
|
||||
v-if="formData.thumb"
|
||||
:src="getImageUrl(formData.thumb)"
|
||||
class="image-preview"
|
||||
/>
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="image-uploader-icon"><Plus /></el-icon>
|
||||
<div class="el-upload__text">点击上传服务图标</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div
|
||||
style="
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
"
|
||||
>
|
||||
建议尺寸:200x200,支持 jpg、png、gif 格式,大小不超过 2MB
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="formData.thumb"
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleRemoveImage"
|
||||
style="margin-top: 8px"
|
||||
>
|
||||
删除图标
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:loading="submitLoading"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { addService, updateService } from "@/api/services";
|
||||
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "添加服务",
|
||||
},
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rowData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "success"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const formRef = ref(null);
|
||||
|
||||
// 上传配置
|
||||
const uploadUrl = ref(API_BASE_URL + "/platform/uploadfiles");
|
||||
const uploadHeaders = ref({
|
||||
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
|
||||
});
|
||||
|
||||
// 图片上传前校验
|
||||
const beforeImageUpload = (file) => {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error("请上传图片文件!");
|
||||
return false;
|
||||
}
|
||||
if (!isLt2M) {
|
||||
ElMessage.error("图片大小不能超过 2MB!");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 图片上传成功
|
||||
const handleImageSuccess = (response) => {
|
||||
if (response.code === 200 || response.code === 201) {
|
||||
formData.link_logo = response.data.url || response.data.path;
|
||||
ElMessage.success(
|
||||
response.code === 201 ? "文件已存在,直接使用" : "图片上传成功",
|
||||
);
|
||||
} else {
|
||||
ElMessage.error(response.msg || "图片上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 图片上传失败
|
||||
const handleImageError = () => {
|
||||
ElMessage.error("图片上传失败,请重试");
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = () => {
|
||||
formData.link_logo = "";
|
||||
ElMessage.success("图片已删除");
|
||||
};
|
||||
|
||||
// 获取图片完整URL(用于预览)
|
||||
const getImageUrl = (imagePath) => {
|
||||
if (!imagePath) return "";
|
||||
if (imagePath.startsWith("http")) return imagePath;
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
const formData = reactive({
|
||||
title: "",
|
||||
desc: "",
|
||||
thumb: "",
|
||||
url: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
title: [
|
||||
{ required: true, message: "请输入服务名称", trigger: "blur" },
|
||||
{ max: 100, message: "长度不超过100个字符", trigger: "blur" },
|
||||
],
|
||||
desc: [
|
||||
{ required: true, message: "请输入服务描述", trigger: "blur" },
|
||||
{ max: 200, message: "描述长度不超过200个字符", trigger: "blur" },
|
||||
],
|
||||
};
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
resetForm();
|
||||
if (props.isEdit && props.rowData) {
|
||||
Object.assign(formData, props.rowData);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (val) => {
|
||||
emit("update:modelValue", val);
|
||||
});
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.title = "";
|
||||
formData.desc = "";
|
||||
formData.thumb = "";
|
||||
formData.url = "";
|
||||
formData.sort = 0;
|
||||
formData.status = 1;
|
||||
};
|
||||
|
||||
// 关闭
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
let res;
|
||||
if (props.isEdit) {
|
||||
res = await updateService(props.rowData.id, formData);
|
||||
} else {
|
||||
res = await addService(formData);
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(props.isEdit ? "更新成功" : "添加成功");
|
||||
handleClose();
|
||||
emit("success");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "操作失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("提交失败:", error);
|
||||
ElMessage.error("操作失败");
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 图片上传样式 */
|
||||
.image-uploader {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.image-uploader:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.image-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.el-upload__text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.flex-direction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
</style>
|
||||
@@ -1,281 +0,0 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>特色服务管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
添加内容
|
||||
</el-button>
|
||||
<el-button @click="refresh" :loading="loading">
|
||||
<el-icon>
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchForm.keyword" placeholder="请输入内容搜索" clearable style="width: 200px; margin-right: 10px"
|
||||
@keyup.enter="handleSearch" />
|
||||
<el-select v-model="searchForm.status" placeholder="状态筛选" clearable style="width: 120px; margin-right: 10px">
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon>
|
||||
<Search />
|
||||
</el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="servicesList" style="width: 100%" v-loading="loading" @selection-change="handleSelectionChange">
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="thumb" label="服务图片" min-width="150" align="center" />
|
||||
<el-table-column prop="title" label="服务名称" min-width="150" align="center" />
|
||||
<el-table-column prop="desc" label="服务描述" min-width="150" align="center" />
|
||||
<el-table-column prop="url" label="跳转地址" min-width="150" align="center" />
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">
|
||||
<el-icon>
|
||||
<Edit />
|
||||
</el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
</el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit"
|
||||
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange" @current-change="handlePageChange" />
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑组件 -->
|
||||
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
|
||||
@success="handleSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getServiceList,
|
||||
updateService,
|
||||
deleteService
|
||||
} from '@/api/services'
|
||||
import EditDialog from './components/edit.vue'
|
||||
|
||||
// @ts-ignore
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
// 获取图片完整URL
|
||||
const getImageUrl = (imagePath) => {
|
||||
if (!imagePath) return "";
|
||||
if (imagePath.startsWith("http")) return imagePath;
|
||||
return API_BASE_URL + imagePath;
|
||||
};
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 数据列表
|
||||
const servicesList = ref([])
|
||||
const selectedIds = ref([])
|
||||
|
||||
// 对话框
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const isEdit = ref(false)
|
||||
const currentRow = ref({})
|
||||
|
||||
// 获取列表
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getServiceList({
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
keyword: searchForm.keyword,
|
||||
status: searchForm.status
|
||||
})
|
||||
if (res.code === 200) {
|
||||
servicesList.value = res.data.list
|
||||
pagination.total = res.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取特色服务列表失败:', error)
|
||||
ElMessage.error('获取特色服务列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = ''
|
||||
pagination.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const refresh = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 分页变化
|
||||
const handleSizeChange = (val) => {
|
||||
pagination.limit = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handlePageChange = (val) => {
|
||||
pagination.page = val
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 选择变化
|
||||
const handleSelectionChange = (selection) => {
|
||||
selectedIds.value = selection.map(item => item.id)
|
||||
}
|
||||
|
||||
// 添加
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '添加特色服务'
|
||||
currentRow.value = {}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑特色服务'
|
||||
currentRow.value = row
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该特色服务吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
const res = await deleteService(row.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除特色服务失败:', error)
|
||||
ElMessage.error('删除特色服务失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 状态变化
|
||||
const handleStatusChange = async (row, val) => {
|
||||
try {
|
||||
const res = await updateService(row.id, { status: val })
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('状态更新成功')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '状态更新失败')
|
||||
row.status = val === 1 ? 0 : 1
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新特色服务状态失败:', error)
|
||||
ElMessage.error('更新特色服务状态失败')
|
||||
row.status = val === 1 ? 0 : 1
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑成功回调
|
||||
const handleSuccess = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header-bar h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -1,281 +0,0 @@
|
||||
<template>
|
||||
<div class="template-layout">
|
||||
<aside class="category-sidebar">
|
||||
<div class="sidebar-title">模板管理</div>
|
||||
<ul class="category-list">
|
||||
<li class="current-theme">
|
||||
<span>当前使用:</span>
|
||||
<el-tag type="success">{{ currentTheme }}</el-tag>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main class="content-area">
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="result-count">共 {{ templateList.length }} 个模板</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button type="primary" @click="fetchTemplates" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新模板
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-divider></el-divider>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载模板中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 模板列表 -->
|
||||
<div v-else class="template-grid">
|
||||
<div v-for="item in templateList" :key="item.key" class="template-card" :class="{ active: item.key === currentTheme }">
|
||||
<div class="card-preview">
|
||||
<img :src="getPreviewUrl(item.preview)" alt="preview" @error="handleImageError($event)" />
|
||||
<div v-if="item.key === currentTheme" class="current-tag">
|
||||
<el-tag type="success" size="small">使用中</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-info">
|
||||
<h4 class="title">{{ item.name }}</h4>
|
||||
<p class="description">{{ item.description }}</p>
|
||||
<div class="meta">
|
||||
<span class="version">v{{ item.version }}</span>
|
||||
<span v-if="item.author" class="author">{{ item.author }}</span>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<el-button
|
||||
v-if="item.key !== currentTheme"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleUse(item)"
|
||||
:loading="switching === item.key"
|
||||
>
|
||||
启用
|
||||
</el-button>
|
||||
<el-button v-else type="info" size="small" disabled>
|
||||
当前使用
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, Loading } from '@element-plus/icons-vue'
|
||||
import { getThemeList, switchTheme } from '@/api/theme'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const switching = ref('')
|
||||
const templateList = ref<any[]>([])
|
||||
const currentTheme = ref('default')
|
||||
const currentTid = ref<number>(0)
|
||||
|
||||
// 获取完整预览图URL
|
||||
const getPreviewUrl = (path: string) => {
|
||||
if (!path) return ''
|
||||
if (path.startsWith('http')) return path
|
||||
return API_BASE_URL + path
|
||||
}
|
||||
|
||||
// 获取模板列表
|
||||
const fetchTemplates = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
currentTid.value = (authStore.user as any)?.tid || 0
|
||||
const res = await getThemeList({ tid: currentTid.value })
|
||||
if (res.code === 200) {
|
||||
templateList.value = res.data.list || []
|
||||
currentTheme.value = res.data.currentTheme || 'default'
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取模板列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取模板列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换模板
|
||||
const handleUse = async (item: any) => {
|
||||
if (!currentTid.value) {
|
||||
ElMessage.error('请先选择租户')
|
||||
return
|
||||
}
|
||||
switching.value = item.key
|
||||
try {
|
||||
const res = await switchTheme({ tid: currentTid.value, theme_key: item.key })
|
||||
if (res.code === 200) {
|
||||
currentTheme.value = item.key
|
||||
ElMessage.success('切换成功')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '切换失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('切换失败')
|
||||
} finally {
|
||||
switching.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 图片加载失败处理
|
||||
const handleImageError = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.src = 'https://picsum.photos/300/200?random=1'
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.template-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background-color: #f0f2f5;
|
||||
|
||||
.category-sidebar {
|
||||
width: 240px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
padding: 20px 0;
|
||||
|
||||
.sidebar-title {
|
||||
padding: 0 24px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.current-theme {
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 0 24px 24px;
|
||||
overflow-y: auto;
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
|
||||
.result-count {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
color: #999;
|
||||
gap: 10px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid #f0f0f0;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.active {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
height: 200px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.current-tag {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
padding: 16px;
|
||||
|
||||
.title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0 0 12px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,278 +0,0 @@
|
||||
<template>
|
||||
<div class="exam-workbench">
|
||||
<!-- 数据统计 -->
|
||||
<div class="statistics-section">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fa-solid fa-file-lines"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.examCount }}</div>
|
||||
<div class="stat-label">考试总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.practiceCount }}</div>
|
||||
<div class="stat-label">练习总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fa-solid fa-book"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.courseCount }}</div>
|
||||
<div class="stat-label">课程总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fa-solid fa-users"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ statistics.studentCount }}</div>
|
||||
<div class="stat-label">考生总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 快捷功能 -->
|
||||
<div class="quick-actions">
|
||||
<h3 class="section-title">快捷功能</h3>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleCreateExam">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-file-circle-plus"></i>
|
||||
</div>
|
||||
<div class="action-label">创建考试</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleCreatePractice">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</div>
|
||||
<div class="action-label">创建练习</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleCreateCourse">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-book-open"></i>
|
||||
</div>
|
||||
<div class="action-label">创建课程</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleBatchImport">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-file-import"></i>
|
||||
</div>
|
||||
<div class="action-label">批量导题</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleStudentManage">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-user-group"></i>
|
||||
</div>
|
||||
<div class="action-label">考生管理</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<div class="action-card" @click="handleQuestionBank">
|
||||
<div class="action-icon">
|
||||
<i class="fa-solid fa-database"></i>
|
||||
</div>
|
||||
<div class="action-label">试题库</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const statistics = ref({
|
||||
examCount: 0,
|
||||
practiceCount: 0,
|
||||
courseCount: 0,
|
||||
studentCount: 0
|
||||
});
|
||||
|
||||
const handleCreateExam = () => {
|
||||
router.push('/apps/exams/exam');
|
||||
};
|
||||
|
||||
const handleCreatePractice = () => {
|
||||
ElMessage.info('创建练习功能开发中');
|
||||
};
|
||||
|
||||
const handleCreateCourse = () => {
|
||||
ElMessage.info('创建课程功能开发中');
|
||||
};
|
||||
|
||||
const handleBatchImport = () => {
|
||||
ElMessage.info('批量导题功能开发中');
|
||||
};
|
||||
|
||||
const handleStudentManage = () => {
|
||||
ElMessage.info('考生管理功能开发中');
|
||||
};
|
||||
|
||||
const handleQuestionBank = () => {
|
||||
ElMessage.info('试题库功能开发中');
|
||||
};
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
// TODO: 调用API获取统计数据
|
||||
statistics.value = {
|
||||
examCount: 0,
|
||||
practiceCount: 0,
|
||||
courseCount: 0,
|
||||
studentCount: 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStatistics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.exam-workbench {
|
||||
padding: 20px;
|
||||
|
||||
.statistics-section {
|
||||
margin-bottom: 30px;
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
i {
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.action-icon {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
|
||||
i {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: #f5f7fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
transition: all 0.3s;
|
||||
|
||||
i {
|
||||
font-size: 32px;
|
||||
color: #667eea;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +0,0 @@
|
||||
<script setup>
|
||||
// Apps 父路由组件,用于显示子路由
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Apps 父路由容器 */
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="wang-editor-wrapper" :class="{ focused: isFocused }">
|
||||
<div ref="toolbarRef" class="toolbar-container"></div>
|
||||
<div ref="editorRef" class="editor-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const toolbarRef = ref(null);
|
||||
const editorRef = ref(null);
|
||||
const isFocused = ref(false);
|
||||
let editorInstance = null;
|
||||
let isDestroyed = false;
|
||||
|
||||
// 上传图片处理函数
|
||||
const handleUploadImage = async (file, insertFn) => {
|
||||
try {
|
||||
// 这里替换为实际的上传逻辑
|
||||
// const formData = new FormData();
|
||||
// formData.append('file', file);
|
||||
// const response = await uploadFile(formData);
|
||||
|
||||
// 模拟上传
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
insertFn(e.target.result, file.name);
|
||||
ElMessage.success('图片上传成功');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
ElMessage.error('上传失败:' + (error.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化编辑器
|
||||
const initEditor = async () => {
|
||||
if (!editorRef.value || !toolbarRef.value || isDestroyed) return;
|
||||
|
||||
try {
|
||||
const { createEditor, createToolbar } = await import("@wangeditor/editor");
|
||||
|
||||
const editorConfig = {
|
||||
placeholder: "请输入内容...",
|
||||
onChange: (editor) => {
|
||||
if (!isDestroyed) {
|
||||
const html = editor.getHtml();
|
||||
emit("update:modelValue", html);
|
||||
}
|
||||
},
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
customUpload: async (file, insertFn) => {
|
||||
await handleUploadImage(file, insertFn);
|
||||
},
|
||||
allowedFileTypes: ["image/*"],
|
||||
maxFileSize: 5 * 1024 * 1024, // 5MB
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
editorInstance = createEditor({
|
||||
selector: editorRef.value,
|
||||
html: props.modelValue || "",
|
||||
config: editorConfig,
|
||||
mode: "default",
|
||||
});
|
||||
|
||||
createToolbar({
|
||||
editor: editorInstance,
|
||||
selector: toolbarRef.value,
|
||||
config: {},
|
||||
});
|
||||
|
||||
nextTick(() => {
|
||||
if (editorInstance) {
|
||||
const editorDom = editorRef.value;
|
||||
if (editorDom) {
|
||||
const textDom = editorDom.querySelector(".w-e-text");
|
||||
if (textDom) {
|
||||
textDom.addEventListener("focus", () => {
|
||||
isFocused.value = true;
|
||||
});
|
||||
textDom.addEventListener("blur", () => {
|
||||
isFocused.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize editor:", error);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (editorInstance && newVal !== editorInstance.getHtml()) {
|
||||
editorInstance.setHtml(newVal || "");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
clear: () => {
|
||||
if (editorInstance) {
|
||||
editorInstance.clear();
|
||||
}
|
||||
},
|
||||
getContent: () => {
|
||||
if (editorInstance) {
|
||||
return editorInstance.getHtml();
|
||||
}
|
||||
return props.modelValue;
|
||||
},
|
||||
setContent: (content) => {
|
||||
if (editorInstance) {
|
||||
editorInstance.setHtml(content || "");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
initEditor();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
isDestroyed = true;
|
||||
if (editorInstance) {
|
||||
editorInstance.destroy();
|
||||
editorInstance = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.wang-editor-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--el-bg-color);
|
||||
transition: border-color 0.3s ease;
|
||||
|
||||
&.focused {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.toolbar-container {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--el-fill-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--el-border-color);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-border-color-darker);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.w-e-text),
|
||||
:deep(.w-e-text-container) {
|
||||
padding: 16px;
|
||||
min-height: 400px;
|
||||
|
||||
p {
|
||||
color: var(--el-text-color-primary) !important;
|
||||
margin: 8px 0 !important;
|
||||
line-height: 1.8 !important;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--el-text-color-primary) !important;
|
||||
font-weight: 600 !important;
|
||||
margin: 16px 0 8px !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--el-color-primary) !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--el-fill-color-light) !important;
|
||||
color: var(--el-text-color-primary) !important;
|
||||
border: 1px solid var(--el-border-color) !important;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--el-fill-color-light) !important;
|
||||
border: 1px solid var(--el-border-color) !important;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin: 12px 0;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid var(--el-color-primary) !important;
|
||||
background: var(--el-fill-color-light) !important;
|
||||
color: var(--el-text-color-regular) !important;
|
||||
padding: 8px 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse !important;
|
||||
border: 1px solid var(--el-border-color) !important;
|
||||
width: 100% !important;
|
||||
margin: 12px 0;
|
||||
|
||||
th, td {
|
||||
border: 1px solid var(--el-border-color) !important;
|
||||
padding: 8px 12px !important;
|
||||
min-width: 60px;
|
||||
color: var(--el-text-color-primary) !important;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--el-fill-color-light) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
color: var(--el-text-color-primary) !important;
|
||||
padding-left: 24px !important;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
li {
|
||||
color: var(--el-text-color-primary) !important;
|
||||
line-height: 1.8 !important;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: 1px solid var(--el-border-color) !important;
|
||||
margin: 16px 0 !important;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100% !important;
|
||||
border-radius: 4px !important;
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="note-editor-container">
|
||||
<div class="editor-header">
|
||||
<el-input
|
||||
v-model="noteTitle"
|
||||
placeholder="请输入标题..."
|
||||
class="title-input"
|
||||
@blur="handleTitleChange"
|
||||
/>
|
||||
<div class="editor-actions">
|
||||
<el-button type="primary" @click="handleSave">
|
||||
<el-icon><DocumentChecked /></el-icon>
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-body">
|
||||
<WangEditor v-model="noteContent" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { DocumentChecked } from '@element-plus/icons-vue';
|
||||
import WangEditor from './WangEditor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
noteId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save', 'update-title']);
|
||||
|
||||
const noteTitle = ref('');
|
||||
const noteContent = ref('');
|
||||
|
||||
// 模拟从父组件或 API 加载笔记数据
|
||||
const loadNote = () => {
|
||||
// 实际使用时,这里应该根据 props.noteId 从 API 加载数据
|
||||
// 现在只是演示,从父组件的 notes 数组中获取
|
||||
noteTitle.value = '新建笔记';
|
||||
noteContent.value = '';
|
||||
};
|
||||
|
||||
// 保存笔记
|
||||
const handleSave = () => {
|
||||
emit('save', {
|
||||
id: props.noteId,
|
||||
title: noteTitle.value,
|
||||
content: noteContent.value,
|
||||
});
|
||||
};
|
||||
|
||||
// 标题变更
|
||||
const handleTitleChange = () => {
|
||||
emit('update-title', {
|
||||
id: props.noteId,
|
||||
title: noteTitle.value,
|
||||
});
|
||||
};
|
||||
|
||||
// 监听笔记 ID 变化
|
||||
watch(
|
||||
() => props.noteId,
|
||||
() => {
|
||||
loadNote();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 自动保存(可选)
|
||||
let autoSaveTimer = null;
|
||||
watch([noteTitle, noteContent], () => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
}
|
||||
|
||||
autoSaveTimer = setTimeout(() => {
|
||||
// 自动保存逻辑(可选)
|
||||
// handleSave();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadNote();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.note-editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: var(--el-fill-color-light);
|
||||
|
||||
.title-input {
|
||||
flex: 1;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
background: var(--el-bg-color);
|
||||
box-shadow: 0 0 0 1px var(--el-border-color) inset;
|
||||
padding: 8px 12px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
|
||||
}
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.wang-editor-wrapper) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,381 @@
|
||||
<template>
|
||||
<div class="notebook-container">
|
||||
<div class="notebook-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3><i class="fa-solid fa-book"></i> 我的笔记</h3>
|
||||
<el-button type="primary" size="small" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建笔记
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索笔记..."
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="notes-list">
|
||||
<div
|
||||
v-for="note in filteredNotes"
|
||||
:key="note.id"
|
||||
:class="['note-item', { active: currentNoteId === note.id }]"
|
||||
@click="handleSelectNote(note)"
|
||||
>
|
||||
<div class="note-item-header">
|
||||
<span class="note-title">{{ note.title || '无标题' }}</span>
|
||||
<el-dropdown trigger="click" @command="(cmd) => handleNoteAction(cmd, note)">
|
||||
<el-icon class="note-more"><MoreFilled /></el-icon>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="delete">
|
||||
<el-icon><Delete /></el-icon> 删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="note-preview">{{ getPreviewText(note.content) }}</div>
|
||||
<div class="note-time">{{ formatTime(note.updated_at) }}</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="filteredNotes.length === 0" description="暂无笔记" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notebook-editor">
|
||||
<NoteEditor
|
||||
v-if="currentNoteId"
|
||||
:note-id="currentNoteId"
|
||||
@save="handleSave"
|
||||
@update-title="handleUpdateTitle"
|
||||
/>
|
||||
<div v-else class="empty-editor">
|
||||
<el-empty description="请选择或创建一个笔记" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Plus, Search, Delete, MoreFilled } from '@element-plus/icons-vue';
|
||||
import NoteEditor from './components/edit.vue';
|
||||
|
||||
const searchKeyword = ref('');
|
||||
const currentNoteId = ref(null);
|
||||
const notes = ref([]);
|
||||
|
||||
// 模拟数据 - 实际使用时替换为 API 调用
|
||||
const mockNotes = [
|
||||
{
|
||||
id: 1,
|
||||
title: '欢迎使用笔记本',
|
||||
content: '<p>这是一个功能丰富的笔记应用</p><p>支持富文本编辑、图片上传等功能</p>',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
// 过滤后的笔记列表
|
||||
const filteredNotes = computed(() => {
|
||||
if (!searchKeyword.value) return notes.value;
|
||||
|
||||
const keyword = searchKeyword.value.toLowerCase();
|
||||
return notes.value.filter(note => {
|
||||
return (
|
||||
note.title?.toLowerCase().includes(keyword) ||
|
||||
getPreviewText(note.content).toLowerCase().includes(keyword)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 获取预览文本
|
||||
const getPreviewText = (html) => {
|
||||
if (!html) return '暂无内容';
|
||||
const text = html.replace(/<[^>]+>/g, '').trim();
|
||||
return text.substring(0, 60) + (text.length > 60 ? '...' : '');
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
|
||||
if (diff < minute) {
|
||||
return '刚刚';
|
||||
} else if (diff < hour) {
|
||||
return `${Math.floor(diff / minute)} 分钟前`;
|
||||
} else if (diff < day) {
|
||||
return `${Math.floor(diff / hour)} 小时前`;
|
||||
} else if (diff < 7 * day) {
|
||||
return `${Math.floor(diff / day)} 天前`;
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新笔记
|
||||
const handleCreate = () => {
|
||||
const newNote = {
|
||||
id: Date.now(),
|
||||
title: '新建笔记',
|
||||
content: '',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
notes.value.unshift(newNote);
|
||||
currentNoteId.value = newNote.id;
|
||||
ElMessage.success('创建成功');
|
||||
};
|
||||
|
||||
// 选择笔记
|
||||
const handleSelectNote = (note) => {
|
||||
currentNoteId.value = note.id;
|
||||
};
|
||||
|
||||
// 搜索笔记
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑已在 computed 中处理
|
||||
};
|
||||
|
||||
// 笔记操作
|
||||
const handleNoteAction = async (command, note) => {
|
||||
if (command === 'delete') {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这条笔记吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
|
||||
const index = notes.value.findIndex(n => n.id === note.id);
|
||||
if (index > -1) {
|
||||
notes.value.splice(index, 1);
|
||||
|
||||
if (currentNoteId.value === note.id) {
|
||||
currentNoteId.value = notes.value[0]?.id || null;
|
||||
}
|
||||
|
||||
ElMessage.success('删除成功');
|
||||
}
|
||||
} catch {
|
||||
// 用户取消删除
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 保存笔记
|
||||
const handleSave = ({ id, content }) => {
|
||||
const note = notes.value.find(n => n.id === id);
|
||||
if (note) {
|
||||
note.content = content;
|
||||
note.updated_at = new Date().toISOString();
|
||||
ElMessage.success('保存成功');
|
||||
}
|
||||
};
|
||||
|
||||
// 更新标题
|
||||
const handleUpdateTitle = ({ id, title }) => {
|
||||
const note = notes.value.find(n => n.id === id);
|
||||
if (note) {
|
||||
note.title = title;
|
||||
note.updated_at = new Date().toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化数据 - 实际使用时从 API 加载
|
||||
notes.value = [...mockNotes];
|
||||
if (notes.value.length > 0) {
|
||||
currentNoteId.value = notes.value[0].id;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.notebook-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 180px);
|
||||
gap: 16px;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notebook-sidebar {
|
||||
width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
overflow: hidden;
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--el-fill-color-light);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
i {
|
||||
margin-right: 8px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.notes-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--el-border-color);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-border-color-darker);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.note-item {
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid transparent;
|
||||
background: var(--el-bg-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary);
|
||||
|
||||
.note-title {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.note-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.note-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note-more {
|
||||
margin-left: 8px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.note-preview {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.note-time {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
.notebook-editor {
|
||||
flex: 1;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
overflow: hidden;
|
||||
|
||||
.empty-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.notebook-container {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.notebook-sidebar {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.notebook-editor {
|
||||
height: 500px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user