更新需求模块

This commit is contained in:
扫地僧 2026-03-09 09:40:03 +08:00
parent 07d3614d9c
commit e291e2c48f
8 changed files with 957 additions and 24 deletions

51
src/api/demand.js Normal file
View File

@ -0,0 +1,51 @@
import request from "@/utils/request";
/**
* 获取需求列表
* @returns {Promise}
*/
export function getDemandList() {
return request({
url: "/admin/demandList",
method: "get",
});
}
/**
* 新增需求
* @param {Object} data 需求数据
* @returns {Promise}
*/
export function addDemand(data) {
return request({
url: "/admin/addDemand",
method: "post",
data,
});
}
/**
* 编辑需求
* @param {number} id 需求ID
* @param {Object} data 需求数据
* @returns {Promise}
*/
export function editDemand(id, data) {
return request({
url: `/admin/editDemand/${id}`,
method: "post",
data,
});
}
/**
* 删除需求
* @param {number} id 需求ID
* @returns {Promise}
*/
export function deleteDemand(id) {
return request({
url: `/admin/deleteDemand/${id}`,
method: "post",
});
}

36
src/api/theme.js Normal file
View File

@ -0,0 +1,36 @@
import request from '@/utils/request'
// 获取模板列表
export function getThemeList() {
return request({
url: '/admin/theme',
method: 'get'
})
}
// 切换模板
export function switchTheme(data) {
return request({
url: '/admin/theme/switch',
method: 'post',
data
})
}
// 获取模板数据
export function getThemeData(params) {
return request({
url: '/admin/theme/data',
method: 'get',
params
})
}
// 保存模板数据
export function saveThemeData(data) {
return request({
url: '/admin/theme/data',
method: 'post',
data
})
}

View File

@ -7,6 +7,7 @@ import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css' import 'element-plus/theme-chalk/dark/css-vars.css'
import '@/assets/less/index.less' import '@/assets/less/index.less'
import '@/assets/css/all.min.css' import '@/assets/css/all.min.css'
import '@/assets/js/all.min.js'
import router from './router' import router from './router'
import { loadAndAddDynamicRoutes } from './router' import { loadAndAddDynamicRoutes } from './router'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'

View File

@ -24,22 +24,6 @@ service.interceptors.request.use(
config.headers['Authorization'] = `Bearer ${token}`; config.headers['Authorization'] = `Bearer ${token}`;
} }
// 禁止 GET 请求缓存:添加时间戳参数到 query string
if (config.method === 'get') {
config.params = {
...config.params,
_t: Date.now()
};
}
// POST/PUT/PATCH 请求也添加时间戳到 query string 防止缓存
if (['post', 'put', 'patch'].includes(config.method?.toLowerCase())) {
config.params = {
...config.params,
_t: Date.now()
};
}
// 对于有 body 的请求POST、PUT、PATCH确保设置 Content-Type // 对于有 body 的请求POST、PUT、PATCH确保设置 Content-Type
if (config.data && ['post', 'put', 'patch'].includes(config.method?.toLowerCase())) { if (config.data && ['post', 'put', 'patch'].includes(config.method?.toLowerCase())) {
if (!config.headers['Content-Type'] && !config.headers['content-type']) { if (!config.headers['Content-Type'] && !config.headers['content-type']) {

View File

@ -0,0 +1,150 @@
<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>

View File

@ -0,0 +1,333 @@
<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>

View File

@ -2,7 +2,6 @@
<router-view /> <router-view />
</template> </template>
<script setup></script> <script lang="ts" setup></script>
<style lang="less" scoped></style> <style lang="less" scoped></style>

View File

@ -0,0 +1,379 @@
<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="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>
<el-button size="small" @click="handlePreview(item)">预览</el-button>
<el-button size="small" @click="handleEdit(item)">编辑数据</el-button>
</div>
</div>
</div>
</div>
</main>
<!-- 预览弹窗 -->
<el-dialog v-model="previewVisible" title="模板预览" width="90%" top="5vh">
<iframe :src="previewUrl" class="preview-iframe" frameborder="0"></iframe>
</el-dialog>
<!-- 编辑数据弹窗 -->
<el-dialog v-model="editVisible" title="编辑模板数据" width="800px">
<el-form :model="editForm" label-width="100px">
<el-form-item label="模板">
<el-input v-model="editForm.theme_key" disabled />
</el-form-item>
<el-form-item label="选择字段">
<el-select v-model="selectedField" placeholder="选择要编辑的字段" @change="handleFieldChange">
<el-option v-for="(label, key) in editForm.fields" :key="key" :label="label" :value="key" />
</el-select>
</el-form-item>
<el-form-item v-if="selectedField" label="字段值">
<el-input
v-model="fieldValue"
type="textarea"
:rows="10"
placeholder="输入 JSON 格式或普通文本"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveData" :loading="saving">保存</el-button>
</template>
</el-dialog>
</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, getThemeData, saveThemeData } from '@/api/theme'
//
const loading = ref(false)
const switching = ref('')
const saving = ref(false)
const templateList = ref<any[]>([])
const currentTheme = ref('default')
const previewVisible = ref(false)
const previewUrl = ref('')
const editVisible = ref(false)
const editForm = ref<any>({})
const selectedField = ref('')
const fieldValue = ref('')
//
const fetchTemplates = async () => {
loading.value = true
try {
const res = await getThemeList()
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) => {
switching.value = item.key
try {
const res = await switchTheme({ 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 handlePreview = (item: any) => {
previewUrl.value = item.path
previewVisible.value = true
}
//
const handleEdit = async (item: any) => {
editForm.value = { ...item, fields: item.fields || {} }
selectedField.value = ''
fieldValue.value = ''
//
try {
const res = await getThemeData({ theme_key: item.key })
if (res.code === 200 && res.data.data) {
//
const savedData = res.data.data
//
for (const key in savedData) {
if (savedData[key]) {
selectedField.value = key
fieldValue.value = typeof savedData[key] === 'object'
? JSON.stringify(savedData[key], null, 2)
: savedData[key]
break
}
}
}
} catch (error) {
console.error('获取模板数据失败', error)
}
editVisible.value = true
}
//
const handleFieldChange = (field: string) => {
fieldValue.value = ''
}
//
const handleSaveData = async () => {
if (!selectedField.value) {
ElMessage.warning('请选择要编辑的字段')
return
}
saving.value = true
try {
const res = await saveThemeData({
theme_key: editForm.value.key,
field_key: selectedField.value,
field_value: fieldValue.value
})
if (res.code === 200) {
ElMessage.success('保存成功')
editVisible.value = false
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (error) {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
//
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;
}
}
}
}
}
.preview-iframe {
width: 100%;
height: 70vh;
border: none;
}
</style>