更新上传接口和文章发布编辑界面

This commit is contained in:
李志强 2025-12-27 16:19:56 +08:00
parent 06779d999f
commit 3c0f01eb36
21 changed files with 2664 additions and 2881 deletions

View File

@ -12,8 +12,6 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
@ -23,7 +21,6 @@ declare module 'vue' {
ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
@ -36,8 +33,8 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs'] ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload'] ElUpload: typeof import('element-plus/es')['ElUpload']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"element-plus": "^2.13.0", "element-plus": "^2.13.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"quill": "^2.0.3",
"unplugin-auto-import": "^20.3.0", "unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^30.0.0", "unplugin-vue-components": "^30.0.0",
"vue": "^3.5.24", "vue": "^3.5.24",

View File

@ -1,5 +1,33 @@
//进行接口API的统一管理 //进行接口API的统一管理
import axios from "axios";
export class UserService { export class update {
/**
* @description
* @param {FormData} formData -
* @return {Promise}
*/
static async uploadImage(formData: FormData) {
// 使用XMLHttpRequest确保正确的multipart/form-data处理
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'http://localhost:8000/index/index/update_imgs');
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
resolve({ data: response, status: xhr.status, statusText: xhr.statusText });
} catch (e) {
resolve({ data: xhr.responseText, status: xhr.status, statusText: xhr.statusText });
}
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
}
};
xhr.onerror = () => reject(new Error('Network Error'));
xhr.send(formData);
});
}
} }

View File

@ -10,4 +10,53 @@ export class article {
static async getArticleDetail(id: string) { static async getArticleDetail(id: string) {
return request("/index/articles/getArticleDetail", { id }, "get"); return request("/index/articles/getArticleDetail", { id }, "get");
} }
/**
* @description
* @param {string} uid - ID
* @return {Promise}
*/
static async getUserArticleList(uid: string) {
return request("/index/articles/getUserArticleList", { uid }, "get");
}
/**
* @description
* @return {Promise}
*/
static async getArticleCategory() {
return request("/index/articles/getArticleCategory", {}, "get");
}
/**
* @description
* @param {object} articleData -
* @return {Promise}
*/
static async publishArticle(articleData: any) {
return request("/index/articles/publishArticle", articleData, "post");
}
/**
* @description
* @param {string} id - ID
* @param {object} articleData -
* @return {Promise}
*/
static async updateArticle(id: string, articleData: any) {
return request(
"/index/articles/updateArticle",
{ id, ...articleData },
"post"
);
}
/**
* @description
* @param {string} id - ID
* @return {Promise}
*/
static async deleteArticle(id: string) {
return request("/index/articles/deleteArticle", { id }, "post");
}
} }

View File

@ -3,11 +3,52 @@ import { request } from "./axios";
export class resource { export class resource {
/** /**
* @description resource文章详情 * @description resource资源详情
* @param {string} id - ID * @param {string} id - ID
* @return {Promise} * @return {Promise}
*/ */
static async getResourceDetail(id: string) { static async getResourceDetail(id: string) {
return request("/index/resources/getResourceDetail", { id }, "get"); return request("/index/resources/getResourceDetail", { id }, "get");
} }
/**
* @description
* @param {string} uid - ID
* @return {Promise}
*/
static async getUserResourceList(uid: string) {
return request("/index/resources/getUserResourceList", { uid }, "get");
}
/**
* @description
* @param {object} resourceData -
* @return {Promise}
*/
static async publishResource(resourceData: any) {
return request("/index/resources/publishResource", resourceData, "post");
}
/**
* @description
* @param {string} id - ID
* @param {object} resourceData -
* @return {Promise}
*/
static async updateResource(id: string, resourceData: any) {
return request(
"/index/resources/updateResource",
{ id, ...resourceData },
"post"
);
}
/**
* @description
* @param {string} id - ID
* @return {Promise}
*/
static async deleteResource(id: string) {
return request("/index/resources/deleteResource", { id }, "post");
}
} }

View File

@ -1,12 +1,20 @@
import { createApp } from "vue"; import { createApp } from "vue";
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import "@/assets/less/global.less"; import "@/assets/less/global.less";
import "@/assets/css/all.css" import "@/assets/css/all.css"
import App from "./App.vue"; import App from "./App.vue";
import router from "@/router"; import router from "@/router";
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import QuillEditor from "@/views/components/QuillEditor.vue"
const pinia = createPinia() const pinia = createPinia()
const app = createApp(App)
createApp(App).use(router).use(pinia).use(ElementPlus).mount("#app"); app.use(router).use(pinia).use(ElementPlus)
// 注册全局组件
app.component('QuillEditor', QuillEditor)
app.mount("#app");

View File

@ -57,10 +57,73 @@ const router = createRouter({
name: "login", name: "login",
component: () => import("@/views/login/index.vue"), component: () => import("@/views/login/index.vue"),
}, },
{
path: "/test-editor",
name: "testEditor",
component: () => import("@/views/test-editor.vue"),
},
{ {
path: "/user/profile", path: "/user/profile",
name: "userProfile", name: "userProfile",
component: () => import("@/views/user/profile/index.vue"), component: () => import("@/views/user/profile/index.vue"),
redirect: "/user/profile/basic", // 默认跳转到基本资料
children: [
{
path: "basic",
name: "profileBasic",
component: () => import("@/views/user/profile/basicInfo.vue"),
},
{
path: "wallet",
name: "profileWallet",
component: () => import("@/views/user/profile/wallet.vue"),
},
{
path: "messages",
name: "profileMessages",
component: () => import("@/views/user/profile/messages.vue"),
},
{
path: "security",
name: "profileSecurity",
component: () => import("@/views/user/profile/security.vue"),
},
{
path: "notifications",
name: "profileNotifications",
component: () => import("@/views/user/profile/notifications.vue"),
},
{
path: "article/list",
name: "articleList",
component: () => import("@/views/user/profile/listArticle.vue"),
},
{
path: "article/publish",
name: "articlePublish",
component: () => import("@/views/user/profile/editArticle.vue"),
},
{
path: "article/edit",
name: "articleEdit",
component: () => import("@/views/user/profile/editArticle.vue"),
},
{
path: "resource/list",
name: "resourceList",
component: () => import("@/views/user/profile/listResource.vue"),
},
{
path: "resource/publish",
name: "resourcePublish",
component: () => import("@/views/user/profile/editResource.vue"),
},
{
path: "resource/edit",
name: "resourceEdit",
component: () => import("@/views/user/profile/editResource.vue"),
},
],
}, },
], ],
}); });

View File

@ -0,0 +1,172 @@
<template>
<div class="quill-editor-wrapper">
<div ref="editorContainer"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import Quill from 'quill'
import 'quill/dist/quill.snow.css'
interface Props {
modelValue?: string
placeholder?: string
height?: number
readonly?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'change', value: string): void
(e: 'blur'): void
(e: 'focus'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: '请输入内容...',
height: 300,
readonly: false
})
const emit = defineEmits<Emits>()
const editorContainer = ref<HTMLDivElement>()
let quill: Quill | null = null
const initEditor = async () => {
if (!editorContainer.value) return
try {
// Quill
const options = {
theme: 'snow',
placeholder: props.placeholder,
readOnly: props.readonly,
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'script': 'sub'}, { 'script': 'super' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'size': ['small', false, 'large', 'huge'] }],
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
[{ 'color': [] }, { 'background': [] }],
[{ 'align': [] }],
['link', 'image', 'video'],
['clean']
]
}
}
// Quill
quill = new Quill(editorContainer.value, options)
//
if (props.modelValue) {
quill.root.innerHTML = props.modelValue
}
//
quill.on('text-change', () => {
const html = quill?.root.innerHTML || ''
emit('update:modelValue', html)
emit('change', html)
})
//
quill.on('selection-change', (range: any) => {
if (range) {
emit('focus')
} else {
emit('blur')
}
})
} catch (error) {
console.error('Quill 初始化失败:', error)
}
}
const destroyEditor = () => {
if (quill) {
// Quill destroy
quill = null
}
}
// modelValue
watch(() => props.modelValue, (newValue) => {
if (quill && newValue !== quill.root.innerHTML) {
quill.root.innerHTML = newValue || ''
}
})
// readonly
watch(() => props.readonly, (newValue) => {
if (quill) {
quill.enable(!newValue)
}
})
onMounted(async () => {
await nextTick()
initEditor()
})
onBeforeUnmount(() => {
destroyEditor()
})
//
defineExpose({
getQuill: () => quill,
getHtml: () => quill?.root.innerHTML || '',
getText: () => quill?.getText() || '',
setHtml: (html: string) => {
if (quill) {
quill.root.innerHTML = html
}
},
focus: () => {
if (quill) {
quill.focus()
}
},
blur: () => {
if (quill) {
quill.blur()
}
}
})
</script>
<style lang="less" scoped>
.quill-editor-wrapper {
:deep(.ql-toolbar) {
border-top: 1px solid #ccc !important;
border-left: 1px solid #ccc !important;
border-right: 1px solid #ccc !important;
border-bottom: none !important;
border-radius: 4px 4px 0 0;
}
:deep(.ql-container) {
border: 1px solid #ccc !important;
border-radius: 0 0 4px 4px;
min-height: v-bind('props.height + "px"');
}
:deep(.ql-editor) {
min-height: v-bind('props.height - 42 + "px"');
padding: 12px;
}
:deep(.ql-editor.ql-blank::before) {
color: #999;
font-style: normal;
}
}
</style>

View File

@ -0,0 +1,137 @@
# QuillEditor 富文本编辑器组件
## 概述
QuillEditor 是一个基于 Vue 3 的富文本编辑器组件,封装了 Quill.js 富文本编辑器库,提供了简单易用的 API。
## 全局注册
组件已自动在 `main.ts` 中注册为全局组件,可在任何地方直接使用。
## 基本用法
```vue
<template>
<quill-editor
v-model="content"
placeholder="请输入内容..."
:height="300"
@change="handleChange"
/>
</template>
<script setup>
import { ref } from 'vue'
const content = ref('')
const handleChange = (html) => {
console.log('内容变化:', html)
}
</script>
```
## Props
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| modelValue | string | '' | 编辑器内容(双向绑定) |
| placeholder | string | '请输入内容...' | 占位符文本 |
| height | number | 300 | 编辑器高度px |
| readonly | boolean | false | 是否只读 |
| config | object | {} | 编辑器配置对象 |
## Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| update:modelValue | string | 内容变化时触发v-model |
| change | string | 内容变化时触发 |
| blur | - | 失去焦点时触发 |
| focus | - | 获得焦点时触发 |
## Methods
组件暴露了以下方法,可以通过 ref 调用:
```vue
<template>
<quill-editor ref="editorRef" v-model="content" />
</template>
<script setup>
import { ref } from 'vue'
const editorRef = ref()
// 获取 HTML 内容
const getHtml = () => {
return editorRef.value.getHtml()
}
// 获取纯文本内容
const getText = () => {
return editorRef.value.getText()
}
// 设置 HTML 内容
const setContent = () => {
editorRef.value.setHtml('<p>新的内容</p>')
}
// 获取编辑器实例
const getEditor = () => {
return editorRef.value.getEditor()
}
// 聚焦
editorRef.value.focus()
// 失焦
editorRef.value.blur()
</script>
```
## 配置选项
可以通过 `config` prop 传递更多配置:
```vue
<quill-editor
:config="{
placeholder: '自定义占位符',
readOnly: false,
toolbarConfig: {
toolbarKeys: ['bold', 'italic', 'underline', '|', 'color', 'bgColor']
}
}"
/>
```
## 工具栏功能
默认工具栏包含完整的格式化功能:
- **文本格式:** 加粗、斜体、下划线、删除线、上标、下标
- **标题:** 1-6级标题
- **列表:** 有序列表、无序列表
- **引用和代码:** 引用块、代码块
- **颜色:** 文字颜色、背景色
- **对齐:** 左对齐、居中、右对齐、两端对齐
- **字体大小:** 小、正常、大、巨大
- **缩进:** 增加/减少缩进
- **多媒体:** 链接、图片、视频
- **清除格式:** 清除所有格式
工具栏支持自定义配置。
## 注意事项
1. 组件会自动处理编辑器的创建和销毁
2. 支持 v-model 双向绑定
3. 在组件卸载时会自动清理编辑器实例
4. 支持响应式高度调整
5. 默认包含完整的工具栏功能
## 示例
查看 `src/views/user/profile/editArticle.vue` 中的实际使用示例。

View File

@ -0,0 +1,102 @@
<template>
<div class="test-editor">
<h2>WangEditor 测试页面</h2>
<div class="editor-section">
<h3>富文本编辑器</h3>
<quill-editor
v-model="content"
placeholder="在这里输入内容..."
:height="400"
@change="handleChange"
/>
</div>
<div class="content-section">
<h3>当前内容 (HTML)</h3>
<pre>{{ content }}</pre>
</div>
<div class="actions">
<el-button @click="setContent">设置内容</el-button>
<el-button @click="getHtml">获取HTML</el-button>
<el-button @click="getText">获取文本</el-button>
<el-button @click="clearContent">清空内容</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const content = ref('<p>这是初始内容</p>')
const handleChange = (html: string) => {
console.log('内容变化:', html)
}
const setContent = () => {
content.value = '<p><strong>这是新设置的内容</strong></p><p>支持 <em>多种格式</em></p>'
}
const getHtml = () => {
ElMessage.info(`HTML长度: ${content.value.length}`)
}
const getText = () => {
const text = content.value.replace(/<[^>]*>/g, '')
ElMessage.info(`纯文本: ${text}`)
}
const clearContent = () => {
content.value = ''
}
</script>
<style lang="less" scoped>
.test-editor {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
h2 {
color: #333;
margin-bottom: 30px;
}
.editor-section {
margin-bottom: 30px;
h3 {
margin-bottom: 15px;
color: #666;
}
}
.content-section {
margin-bottom: 30px;
h3 {
margin-bottom: 15px;
color: #666;
}
pre {
background: #f5f5f5;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 200px;
overflow-y: auto;
}
}
.actions {
display: flex;
gap: 10px;
}
}
</style>

View File

@ -1,569 +0,0 @@
<template>
<div class="content-section">
<div class="content-header">
<h2 class="content-title">文章管理</h2>
<p class="content-desc">管理您的技术文章</p>
</div>
<div class="content-body">
<!-- 操作栏 -->
<div class="action-bar">
<el-button type="primary" @click="showPublishDialog = true">
<i class="fa-solid fa-plus"></i>
发布文章
</el-button>
<div class="search-box">
<el-input
v-model="searchText"
placeholder="搜索文章..."
clearable
@input="handleSearch"
>
<template #prefix>
<i class="fa-solid fa-search"></i>
</template>
</el-input>
</div>
</div>
<!-- 文章列表 -->
<div class="article-list">
<div v-if="filteredArticles.length === 0" class="empty-state">
<i class="fa-solid fa-file-alt"></i>
<p>{{ searchText ? '未找到相关文章' : '暂无文章' }}</p>
</div>
<div v-else class="article-grid">
<div
v-for="article in paginatedArticles"
:key="article.id"
class="article-card"
>
<div class="article-content-area">
<div class="article-header">
<el-tag :type="getStatusType(article.status)" size="small">
{{ getStatusText(article.status) }}
</el-tag>
<h3 class="article-title">{{ article.title }}</h3>
</div>
<div class="article-meta">
<span class="article-category">{{ getCategoryText(article.category) }}</span>
<span class="article-date">{{ article.createTime }}</span>
<span class="article-views">{{ article.views }} 阅读</span>
</div>
<div class="article-content">
{{ article.content.substring(0, 100) }}...
</div>
</div>
<div class="article-actions">
<el-button size="small" @click="editArticle(article)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button>
</div>
</div>
</div>
<!-- 分页组件 -->
<div v-if="filteredArticles.length > 0" class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20, 50]"
:total="totalArticles"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</div>
<!-- 发布/编辑文章弹窗 -->
<el-dialog
v-model="showPublishDialog"
:title="isEditMode ? '编辑文章' : '发布文章'"
width="800px"
:close-on-click-modal="false"
>
<el-form :model="articleForm" label-width="80px" :rules="formRules" ref="formRef">
<el-form-item label="标题" prop="title" required>
<el-input
v-model="articleForm.title"
placeholder="请输入文章标题"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="分类" prop="category" required>
<el-select v-model="articleForm.category" placeholder="请选择分类">
<el-option
v-for="category in categories"
:key="category.value"
:label="category.label"
:value="category.value"
/>
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-input
v-model="articleForm.tags"
placeholder="请输入标签,用逗号分隔"
/>
</el-form-item>
<el-form-item label="内容" prop="content" required>
<el-input
v-model="articleForm.content"
type="textarea"
:rows="12"
placeholder="请输入文章内容..."
maxlength="10000"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showPublishDialog = false">取消</el-button>
<el-button type="primary" @click="submitArticle" :loading="loading">
{{ isEditMode ? '保存修改' : '发布文章' }}
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
interface ArticleForm {
title: string;
category: string;
tags: string;
content: string;
}
interface Article {
id: string;
title: string;
content: string;
category: string;
tags: string;
status: 'draft' | 'published' | 'hidden';
createTime: string;
updateTime: string;
views: number;
}
interface Category {
value: string;
label: string;
}
//
const showPublishDialog = ref(false);
const isEditMode = ref(false);
const searchText = ref('');
const loading = ref(false);
const formRef = ref();
//
const currentPage = ref(1);
const pageSize = ref(10);
const totalArticles = computed(() => filteredArticles.value.length);
//
const articleForm = ref<ArticleForm>({
title: '',
category: '',
tags: '',
content: ''
});
//
const formRules = {
title: [
{ required: true, message: '请输入文章标题', trigger: 'blur' },
{ min: 1, max: 100, message: '标题长度在 1 到 100 个字符', trigger: 'blur' }
],
category: [
{ required: true, message: '请选择文章分类', trigger: 'change' }
],
content: [
{ required: true, message: '请输入文章内容', trigger: 'blur' },
{ min: 10, message: '内容不能少于10个字符', trigger: 'blur' }
]
};
//
const articles = ref<Article[]>([
{
id: '1',
title: 'Vue 3 响应式原理详解',
content: 'Vue 3 的响应式系统基于 Proxy 实现,与 Vue 2 的 Object.defineProperty 相比具有更好的性能和更完整的响应式覆盖...',
category: 'frontend',
tags: 'Vue,响应式,Proxy',
status: 'published',
createTime: '2024-01-15',
updateTime: '2024-01-15',
views: 1250
},
{
id: '2',
title: 'Go 语言高并发编程实践',
content: '在 Go 语言中,高并发编程是其核心特性之一。通过 goroutine 和 channel我们可以轻松实现并发编程...',
category: 'backend',
tags: 'Go,并发,goroutine',
status: 'published',
createTime: '2024-01-10',
updateTime: '2024-01-12',
views: 890
}
]);
//
const categories = ref<Category[]>([
{ value: 'frontend', label: '前端开发' },
{ value: 'backend', label: '后端开发' },
{ value: 'mobile', label: '移动开发' },
{ value: 'ai', label: '人工智能' },
{ value: 'devops', label: 'DevOps' },
{ value: 'other', label: '其他' }
]);
//
const filteredArticles = computed(() => {
let filtered = articles.value;
//
if (searchText.value) {
filtered = filtered.filter(article =>
article.title.toLowerCase().includes(searchText.value.toLowerCase()) ||
article.content.toLowerCase().includes(searchText.value.toLowerCase()) ||
article.tags.toLowerCase().includes(searchText.value.toLowerCase())
);
}
return filtered;
});
//
const paginatedArticles = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return filteredArticles.value.slice(start, end);
});
//
const handlePageChange = (page: number) => {
currentPage.value = page;
};
const handleSizeChange = (size: number) => {
pageSize.value = size;
currentPage.value = 1; //
};
//
const getCategoryText = (category: string) => {
const cat = categories.value.find(c => c.value === category);
return cat ? cat.label : category;
};
//
const getStatusText = (status: string) => {
switch (status) {
case 'draft': return '草稿';
case 'published': return '已发布';
case 'hidden': return '隐藏';
default: return status;
}
};
//
const getStatusType = (status: string) => {
switch (status) {
case 'draft': return '';
case 'published': return 'success';
case 'hidden': return 'warning';
default: return '';
}
};
//
const handleSearch = () => {
//
};
//
const editArticle = (article: Article) => {
isEditMode.value = true;
articleForm.value = {
title: article.title,
category: article.category,
tags: article.tags,
content: article.content
};
showPublishDialog.value = true;
};
//
const viewArticle = () => {
ElMessage.info('查看文章功能开发中...');
};
//
const deleteArticle = async (article: Article) => {
try {
await ElMessageBox.confirm(
`确定要删除文章"${article.title}"吗?此操作不可恢复。`,
'确认删除',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
}
);
// API
const index = articles.value.findIndex(a => a.id === article.id);
if (index > -1) {
articles.value.splice(index, 1);
//
const totalPages = Math.ceil(articles.value.length / pageSize.value);
if (currentPage.value > totalPages && totalPages > 0) {
currentPage.value = totalPages;
}
ElMessage.success('文章删除成功');
}
} catch (error) {
//
}
};
//
const submitArticle = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return;
loading.value = true;
try {
if (isEditMode.value) {
//
ElMessage.success('文章更新成功');
} else {
//
const newArticle: Article = {
id: Date.now().toString(),
title: articleForm.value.title,
content: articleForm.value.content,
category: articleForm.value.category,
tags: articleForm.value.tags,
status: 'published',
createTime: new Date().toLocaleDateString(),
updateTime: new Date().toLocaleDateString(),
views: 0
};
articles.value.unshift(newArticle);
currentPage.value = 1; //
ElMessage.success('文章发布成功');
}
showPublishDialog.value = false;
resetForm();
} catch (error) {
ElMessage.error('操作失败,请重试');
} finally {
loading.value = false;
}
});
};
//
const resetForm = () => {
articleForm.value = {
title: '',
category: '',
tags: '',
content: ''
};
if (formRef.value) {
formRef.value.clearValidate();
}
};
//
const initArticles = () => {
// API
};
onMounted(() => {
initArticles();
});
</script>
<style lang="less" scoped>
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
gap: 16px;
.search-box {
width: 300px;
}
}
.article-list {
.article-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
}
.article-card {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 20px;
transition: all 0.3s ease;
display: flex;
align-items: flex-start;
gap: 16px;
&:hover {
border-color: #1677ff;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.15);
}
.article-content-area {
flex: 1;
min-width: 0; //
.article-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
.article-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0;
flex: 1;
margin-left: 12px;
}
}
.article-meta {
display: flex;
gap: 16px;
margin-bottom: 12px;
font-size: 12px;
color: #909399;
.article-category {
color: #1677ff;
font-weight: 500;
}
}
.article-content {
color: #606266;
line-height: 1.6;
margin-bottom: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-clamp: 3;
}
}
.article-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
.el-button {
width: 80px;
justify-content: center;
}
}
}
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
i {
font-size: 48px;
margin-bottom: 16px;
display: block;
}
p {
font-size: 14px;
margin: 0;
}
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 32px;
padding: 20px 0;
.el-pagination {
::v-deep(.el-pager li) {
min-width: 36px;
height: 36px;
line-height: 36px;
border-radius: 6px;
margin: 0 2px;
}
::v-deep(.el-pager li.is-active) {
background-color: #1677ff;
color: white;
}
::v-deep(.btn-prev, .btn-next) {
min-width: 36px;
height: 36px;
border-radius: 6px;
}
}
}
/* 弹窗表单样式 */
.el-dialog {
::v-deep(.el-dialog__body) {
padding: 20px 24px;
}
.el-form {
.el-form-item {
margin-bottom: 20px;
}
.el-textarea {
::v-deep(.el-textarea__inner) {
resize: vertical;
min-height: 200px;
}
}
}
}
</style>

View File

@ -0,0 +1,470 @@
<template>
<div class="content-section">
<div class="content-header">
<h2 class="content-title">{{ isEditMode ? "编辑文章" : "发布文章" }}</h2>
<p class="content-desc">
{{ isEditMode ? "修改您的文章内容" : "分享您的技术见解" }}
</p>
</div>
<div class="content-body">
<el-form
:model="articleForm"
label-width="80px"
:rules="formRules"
ref="formRef"
>
<el-form-item label="标题" prop="title" required>
<el-input
v-model="articleForm.title"
placeholder="请输入文章标题"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="分类" prop="category" required>
<el-tree-select
v-model="articleForm.category"
placeholder="请选择分类"
:data="categories"
:props="{ label: 'name', value: 'id', children: 'children' }"
:render-after-expand="false"
filterable
check-strictly
/>
</el-form-item>
<el-form-item label="作者" required>
<el-input v-model="articleForm.author" placeholder="请输入作者" />
</el-form-item>
<el-form-item label="标签">
<el-input
v-model="articleForm.tags"
placeholder="请输入标签,用逗号分隔"
/>
</el-form-item>
<el-form-item label="是否转载" required>
<el-radio-group v-model="articleForm.is_trans" size="default">
<el-radio-button label="是" value="1"></el-radio-button>
<el-radio-button label="否" value="0"></el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="articleForm.is_trans === '1'"
label="转载链接"
required
>
<el-input v-model="articleForm.transurl" placeholder="请输入链接" />
</el-form-item>
<el-form-item label="描述">
<el-input
type="textarea"
:rows="4"
v-model="articleForm.desc"
placeholder="请输入描述"
/>
</el-form-item>
<el-form-item label="封面图片">
<el-upload
v-model:file-list="fileList"
list-type="picture-card"
:limit="1"
:auto-upload="false"
:on-change="handleFileChange"
:on-remove="handleUploadRemove"
:before-upload="beforeUpload"
>
<el-icon><Plus /></el-icon>
<template #tip>
<div class="el-upload__tip">
请上传封面图片建议尺寸 800x400px
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="内容" prop="content" required>
<quill-editor
v-model="articleForm.content"
placeholder="请输入文章内容..."
:height="400"
/>
</el-form-item>
</el-form>
<div class="action-buttons">
<el-button @click="goBack">取消</el-button>
<el-button type="primary" @click="submitArticle" :loading="loading">
{{ isEditMode ? "保存修改" : "发布文章" }}
</el-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { article } from "@/api/article";
import { update } from "@/api/api";
interface ArticleForm {
title: string;
category: number | undefined;
tags: string;
author: string;
content: string;
desc: string;
is_trans: string;
transurl: string;
image: string;
}
interface Category {
value: string;
label: string;
}
const router = useRouter();
const route = useRoute();
//
const isEditMode = ref(false);
const loading = ref(false);
const formRef = ref();
const fileList = ref<any[]>([]);
const selectedFile = ref<File | null>(null);
//
const articleForm = ref<ArticleForm>({
title: "",
category: undefined as number | undefined,
tags: "",
desc: "",
author: "",
content: "",
is_trans: "0",
transurl: "",
image: "",
});
//
const formRules = {
title: [
{ required: true, message: "请输入文章标题", trigger: "blur" },
{
min: 1,
max: 100,
message: "标题长度在 1 到 100 个字符",
trigger: "blur",
},
],
category: [
{
required: true,
message: "请选择文章分类",
trigger: "change",
validator: (rule: any, value: any, callback: any) => {
if (value === undefined || value === null || value === 0) {
callback(new Error("请选择文章分类"));
} else {
callback();
}
},
},
],
content: [
{ required: true, message: "请输入文章内容", trigger: "blur" },
{ min: 10, message: "内容不能少于10个字符", trigger: "blur" },
],
};
//
const categories = ref<Category[]>([]);
//
const getArticleCategories = async () => {
const response: any = await article.getArticleCategory();
categories.value = response.data.data;
};
//
const getUserId = () => {
const userStr = localStorage.getItem("user");
if (userStr) {
try {
const user = JSON.parse(userStr);
return user.uid;
} catch (error) {
console.error("解析用户信息失败:", error);
return null;
}
}
return null;
};
//
const initData = async () => {
//
await getArticleCategories();
//
const articleId = route.query.id as string;
if (articleId) {
isEditMode.value = true;
loadArticle(articleId);
}
};
//
const loadArticle = async (articleId: string) => {
loading.value = true;
try {
const response = await article.getArticleDetail(articleId);
if (response.data.code === 0) {
const articleData = response.data.data.article; //
articleForm.value = {
title: articleData.title || "",
category: articleData.cate || undefined,
tags: articleData.tags || "",
desc: articleData.desc || "",
author: articleData.author || "",
content: articleData.content || "",
is_trans: articleData.is_trans || "0",
transurl: articleData.transurl || "",
image: articleData.image || "",
};
// fileList
if (articleData.image) {
// URL
const fullImageUrl = articleData.image.startsWith('http')
? articleData.image
: `http://localhost:8000${articleData.image}`;
fileList.value = [
{
name: "cover.jpg",
url: fullImageUrl,
uid: Date.now(),
},
];
}
} else {
ElMessage.error(response.data.msg || "加载文章失败");
}
} catch (error) {
console.error("加载文章失败:", error);
ElMessage.error("加载文章失败,请重试");
} finally {
loading.value = false;
}
};
//
const handleFileChange = (file: any, fileList: any[]) => {
//
selectedFile.value = file.raw;
};
const uploadCoverImage = async (): Promise<string | null> => {
if (!selectedFile.value) {
return articleForm.value.image; // URL
}
const formData = new FormData();
//
formData.append('file', selectedFile.value, selectedFile.value.name);
try {
const response = await update.uploadImage(formData);
//
if (response.status === 200) {
const responseData = response.data;
if (responseData.code === 0) {
// data.url
const imageUrl = responseData.data?.url;
if (imageUrl) {
ElMessage.success("封面上传成功");
return imageUrl;
} else {
console.error('无法获取图片URL:', responseData);
ElMessage.error("封面上传失败无法获取图片URL");
return null;
}
} else {
ElMessage.error(responseData.msg || "封面上传失败");
return null;
}
} else {
ElMessage.error("封面上传失败:服务器响应异常");
return null;
}
} catch (error: any) {
console.error('上传错误:', error);
const errorMessage = error.response?.data?.msg || error.message || "封面上传失败";
ElMessage.error(errorMessage);
return null;
}
};
const handleUploadRemove = (file: any, fileList: any[]) => {
selectedFile.value = null;
articleForm.value.image = "";
};
const beforeUpload = (file: any) => {
const isImage = file.type.startsWith("image/");
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isImage) {
ElMessage.error("只能上传图片文件!");
return false;
}
if (!isLt5M) {
ElMessage.error("上传图片大小不能超过 5MB!");
return false;
}
return true;
};
//
const submitArticle = async () => {
if (!formRef.value) return;
const userId = getUserId();
if (!userId) {
ElMessage.warning("请先登录");
return;
}
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return;
loading.value = true;
try {
//
const coverImageUrl = await uploadCoverImage();
if (coverImageUrl === null && selectedFile.value) {
//
loading.value = false;
return;
}
const articleData = {
title: articleForm.value.title,
content: articleForm.value.content,
cate: articleForm.value.category || 0,
user_id: userId,
desc: articleForm.value.desc,
author: articleForm.value.author,
is_trans: articleForm.value.is_trans,
transurl:
articleForm.value.is_trans === "1" ? articleForm.value.transurl : "",
image: coverImageUrl || "",
};
if (isEditMode.value) {
//
const articleId = route.query.id as string;
const updateData = {
...articleData,
id: articleId,
};
const response = await article.updateArticle(articleId, updateData);
if (response.data.code === 0) {
ElMessage.success("文章更新成功");
router.push("/user/profile");
} else {
ElMessage.error(response.data.msg || "文章更新失败");
}
} else {
//
const response = await article.publishArticle(articleData);
if (response.data.code === 0) {
ElMessage.success("文章发布成功");
router.push("/user/profile");
} else {
ElMessage.error(response.data.msg || "文章发布失败");
}
}
} catch (error) {
console.error("操作失败:", error);
ElMessage.error("操作失败,请重试");
} finally {
loading.value = false;
}
});
};
//
const goBack = () => {
router.back();
};
onMounted(() => {
initData();
});
</script>
<style lang="less" scoped>
.content-section {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.content-header {
margin-bottom: 32px;
text-align: center;
.content-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.content-desc {
color: #909399;
font-size: 14px;
}
}
.content-body {
background: #fff;
}
.el-form {
.el-form-item {
margin-bottom: 24px;
}
.el-textarea {
::v-deep(.el-textarea__inner) {
resize: vertical;
min-height: 200px;
}
}
}
.action-buttons {
display: flex;
justify-content: flex-end;
gap: 16px;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #e4e7ed;
}
</style>

View File

@ -0,0 +1,364 @@
<template>
<div class="content-section">
<div class="content-header">
<h2 class="content-title">{{ isEditMode ? "编辑资源" : "发布资源" }}</h2>
<p class="content-desc">
{{ isEditMode ? "修改您的资源信息" : "分享您的应用资源" }}
</p>
</div>
<div class="content-body">
<el-form
:model="resourceForm"
label-width="80px"
:rules="formRules"
ref="formRef"
>
<el-form-item label="资源名称" prop="name" required>
<el-input
v-model="resourceForm.name"
placeholder="请输入资源名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="资源类型" prop="type" required>
<el-select v-model="resourceForm.type" placeholder="请选择资源类型">
<el-option
v-for="type in resourceTypes"
:key="type.value"
:label="type.label"
:value="type.value"
/>
</el-select>
</el-form-item>
<el-form-item label="分类" prop="category" required>
<el-checkbox-group v-model="resourceForm.category">
<el-checkbox
v-for="platform in platforms"
:key="platform.value"
:value="platform.value"
>
{{ platform.label }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="版本号">
<el-input v-model="resourceForm.version" placeholder="例如1.0.0" />
</el-form-item>
<el-form-item label="下载链接" prop="downloadUrl" required>
<el-input
v-model="resourceForm.downloadUrl"
placeholder="请输入下载链接"
/>
</el-form-item>
<el-form-item label="资源描述" prop="description" required>
<el-input
v-model="resourceForm.description"
type="textarea"
:rows="4"
placeholder="请输入资源描述..."
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="截图上传">
<el-upload
ref="uploadRef"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
action=""
:auto-upload="false"
multiple
accept="image/*"
list-type="picture-card"
>
<el-icon><Plus /></el-icon>
<div class="upload-text">上传截图</div>
</el-upload>
<div class="upload-tips">
<p>支持 JPGPNGGIF 格式单张图片不超过 2MB最多上传 5 </p>
</div>
</el-form-item>
</el-form>
<div class="action-buttons">
<el-button @click="goBack">取消</el-button>
<el-button type="primary" @click="submitResource" :loading="loading">
{{ isEditMode ? "保存修改" : "发布资源" }}
</el-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { resource } from "@/api/resource";
interface ResourceForm {
name: string;
type: string;
category: string[];
version: string;
downloadUrl: string;
description: string;
}
interface ResourceType {
value: string;
label: string;
}
interface Platform {
value: string;
label: string;
}
const router = useRouter();
const route = useRoute();
//
const isEditMode = ref(false);
const loading = ref(false);
const formRef = ref();
const uploadRef = ref();
const fileList = ref([]);
//
const resourceForm = ref<ResourceForm>({
name: "",
type: "",
category: [],
version: "",
downloadUrl: "",
description: "",
});
//
const formRules = {
name: [
{ required: true, message: "请输入资源名称", trigger: "blur" },
{ min: 1, max: 50, message: "名称长度在 1 到 50 个字符", trigger: "blur" },
],
type: [{ required: true, message: "请选择资源类型", trigger: "change" }],
category: [
{
type: "array",
required: true,
message: "请至少选择一个分类",
trigger: "change",
},
],
downloadUrl: [
{ required: true, message: "请输入下载链接", trigger: "blur" },
{ type: "url", message: "请输入正确的URL格式", trigger: "blur" },
],
description: [
{ required: true, message: "请输入资源描述", trigger: "blur" },
{ min: 10, message: "描述不能少于10个字符", trigger: "blur" },
],
};
//
const resourceTypes = ref<ResourceType[]>([
{ value: "software", label: "软件工具" },
{ value: "plugin", label: "插件扩展" },
{ value: "library", label: "代码库" },
{ value: "template", label: "模板素材" },
{ value: "other", label: "其他" },
]);
//
const platforms = ref<Platform[]>([
{ value: "windows", label: "Windows" },
{ value: "macos", label: "macOS" },
{ value: "linux", label: "Linux" },
{ value: "web", label: "Web" },
{ value: "mobile", label: "移动端" },
]);
//
const initData = () => {
//
const resourceId = route.query.id as string;
if (resourceId) {
isEditMode.value = true;
loadResource(resourceId);
}
};
//
const loadResource = async (resourceId: string) => {
loading.value = true;
try {
const response = await resource.getResourceDetail(resourceId);
if (response.data.code === 0 && response.data.data) {
const resourceData = response.data.data;
resourceForm.value = {
name: resourceData.name || "",
type: resourceData.type || "",
category: resourceData.category || [],
version: resourceData.version || "",
downloadUrl: resourceData.download_url || "",
description: resourceData.description || "",
};
} else {
ElMessage.error(response.data.msg || "加载资源失败");
router.push("/user/profile/resource/list");
}
} catch (error) {
console.error("加载资源失败:", error);
ElMessage.error("加载资源失败,请重试");
router.push("/user/profile/resource/list");
} finally {
loading.value = false;
}
};
//
const handleFileChange = (file: any, fileList: any[]) => {
//
};
const handleFileRemove = (file: any, fileList: any[]) => {
//
};
//
const submitResource = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return;
loading.value = true;
try {
const resourceData = {
name: resourceForm.value.name,
type: resourceForm.value.type,
category: resourceForm.value.category,
version: resourceForm.value.version || "1.0.0",
downloadUrl: resourceForm.value.downloadUrl,
description: resourceForm.value.description,
// user_id
};
if (isEditMode.value) {
//
const resourceId = route.query.id as string;
const response = await resource.updateResource(
resourceId,
resourceData
);
if (response.data.code === 0) {
ElMessage.success("资源更新成功");
router.push("/user/profile/resource/list");
} else {
ElMessage.error(response.data.msg || "资源更新失败");
}
} else {
//
const response = await resource.publishResource(resourceData);
if (response.data.code === 0) {
ElMessage.success("资源发布成功");
router.push("/user/profile/resource/list");
} else {
ElMessage.error(response.data.msg || "资源发布失败");
}
}
} catch (error) {
console.error("操作失败:", error);
ElMessage.error("操作失败,请重试");
} finally {
loading.value = false;
}
});
};
//
const goBack = () => {
router.back();
};
onMounted(() => {
initData();
});
</script>
<style lang="less" scoped>
.content-section {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.content-header {
margin-bottom: 32px;
text-align: center;
.content-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.content-desc {
color: #909399;
font-size: 14px;
}
}
.content-body {
background: #fff;
}
.el-form {
.el-form-item {
margin-bottom: 24px;
}
.el-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 16px;
.el-checkbox {
margin-right: 0;
}
}
.el-textarea {
::v-deep(.el-textarea__inner) {
resize: vertical;
min-height: 100px;
}
}
}
.upload-tips {
margin-top: 8px;
font-size: 12px;
color: #909399;
}
.action-buttons {
display: flex;
justify-content: flex-end;
gap: 16px;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #e4e7ed;
}
</style>

View File

@ -37,26 +37,8 @@
</div> </div>
<div class="profile-main"> <div class="profile-main">
<div class="content-area"> <div class="content-area">
<!-- 基本资料 --> <!-- 子路由视图 -->
<BasicInfo v-if="activeMenu === 'profile-basic'" /> <router-view />
<!-- 我的钱包 -->
<Wallet v-if="activeMenu === 'profile-wallet'" />
<!-- 我的消息 -->
<Messages v-if="activeMenu === 'profile-messages'" />
<!-- 安全设置 -->
<Security v-if="activeMenu === 'profile-security'" />
<!-- 发布文章 -->
<ArticlePublish v-if="activeMenu === 'article-publish'" />
<!-- 发布资源 -->
<ResourcePublish v-if="activeMenu === 'apps-publish'" />
<!-- 系统通知 -->
<Notifications v-if="activeMenu === 'profile-notifications'" />
</div> </div>
</div> </div>
</div> </div>
@ -69,10 +51,14 @@ import BasicInfo from "@/views/user/profile/basicInfo.vue";
import Wallet from "@/views/user/profile/wallet.vue"; import Wallet from "@/views/user/profile/wallet.vue";
import Messages from "@/views/user/profile/messages.vue"; import Messages from "@/views/user/profile/messages.vue";
import Security from "@/views/user/profile/security.vue"; import Security from "@/views/user/profile/security.vue";
import ArticlePublish from "@/views/user/profile/articlePublish.vue"; import ListArticle from "@/views/user/profile/listArticle.vue";
import ResourcePublish from "@/views/user/profile/resourcePublish.vue"; import ListResource from "@/views/user/profile/listResource.vue";
import Notifications from "@/views/user/profile/notifications.vue"; import Notifications from "@/views/user/profile/notifications.vue";
import { ref } from "vue"; import { ref, watch, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
const router = useRouter();
const route = useRoute();
// //
const activeMenu = ref('profile-basic'); // const activeMenu = ref('profile-basic'); //
@ -149,6 +135,23 @@ const menuGroups = ref([
// //
const handleMenuClick = (target: string) => { const handleMenuClick = (target: string) => {
activeMenu.value = target; activeMenu.value = target;
//
if (target === 'profile-basic') {
router.push('/user/profile/basic');
} else if (target === 'profile-wallet') {
router.push('/user/profile/wallet');
} else if (target === 'profile-messages') {
router.push('/user/profile/messages');
} else if (target === 'profile-security') {
router.push('/user/profile/security');
} else if (target === 'profile-notifications') {
router.push('/user/profile/notifications');
} else if (target === 'article-publish') {
router.push('/user/profile/article/list');
} else if (target === 'apps-publish') {
router.push('/user/profile/resource/list');
}
}; };
// / // /
@ -171,8 +174,57 @@ const isGroupCollapsed = (groupName: string) => {
return collapsedGroups.value.includes(groupName); return collapsedGroups.value.includes(groupName);
}; };
//
const setActiveMenuFromRoute = () => {
const currentPath = route.path;
//
collapsedGroups.value = ['personal', 'apps', 'system']; //
if (currentPath.includes('/user/profile/article/publish') ||
currentPath.includes('/user/profile/article/edit') ||
currentPath.includes('/user/profile/article/list')) {
activeMenu.value = 'article-publish';
// apps
collapsedGroups.value = collapsedGroups.value.filter(id => id !== 'apps');
} else if (currentPath.includes('/user/profile/resource/publish') ||
currentPath.includes('/user/profile/resource/edit') ||
currentPath.includes('/user/profile/resource/list')) {
activeMenu.value = 'apps-publish';
// apps
collapsedGroups.value = collapsedGroups.value.filter(id => id !== 'apps');
} else if (currentPath.includes('/user/profile/basic') ||
currentPath.includes('/user/profile/wallet') ||
currentPath.includes('/user/profile/messages') ||
currentPath.includes('/user/profile/security')) {
//
if (currentPath.includes('/user/profile/basic')) {
activeMenu.value = 'profile-basic';
} else if (currentPath.includes('/user/profile/wallet')) {
activeMenu.value = 'profile-wallet';
} else if (currentPath.includes('/user/profile/messages')) {
activeMenu.value = 'profile-messages';
} else if (currentPath.includes('/user/profile/security')) {
activeMenu.value = 'profile-security';
}
// personal
collapsedGroups.value = collapsedGroups.value.filter(id => id !== 'personal');
} else if (currentPath.includes('/user/profile/notifications')) {
activeMenu.value = 'profile-notifications';
// system
collapsedGroups.value = collapsedGroups.value.filter(id => id !== 'system');
}
};
//
watch(() => route.path, () => {
setActiveMenuFromRoute();
});
//
onMounted(() => {
setActiveMenuFromRoute();
});
</script> </script>
<style lang="less"> <style lang="less">

View File

@ -0,0 +1,390 @@
<template>
<div class="content-section">
<div class="content-header">
<h2 class="content-title">文章管理</h2>
<p class="content-desc">管理您的技术文章</p>
</div>
<div class="content-body">
<!-- 操作栏 -->
<div class="action-bar">
<el-button type="primary" @click="goToPublish">
<i class="fa-solid fa-plus"></i>
发布文章
</el-button>
<div class="search-box">
<el-input
v-model="searchText"
placeholder="搜索文章..."
clearable
@input="handleSearch"
>
<template #prefix>
<i class="fa-solid fa-search"></i>
</template>
</el-input>
</div>
</div>
<!-- 文章列表 -->
<div class="article-list">
<div v-if="filteredArticles.length === 0" class="empty-state">
<i class="fa-solid fa-file-alt"></i>
<p>
{{
searchText && articleData.length > 0
? "未找到相关文章"
: "暂无文章"
}}
</p>
</div>
<div v-else class="article-grid">
<div
v-for="item in paginatedArticles"
:key="item.id"
class="article-card"
>
<div class="article-content-area">
<div class="article-header">
<el-tag type="success" size="small">
{{ item.category_name }}
</el-tag>
<h3 class="article-title">{{ item.title }}</h3>
</div>
<div class="article-meta">
<span class="article-author">作者: {{ item.author }}</span>
<span class="article-date">
{{item.update_time ? item.update_time : item.publish_date || item.publishdate}}
</span>
<span class="article-views">{{ item.views }} 阅读</span>
<span class="article-likes">{{ item.likes }} 点赞</span>
</div>
</div>
<div class="article-actions">
<el-button size="small" @click="editArticle(item)"
>编辑</el-button
>
<el-button size="small" type="danger" @click="deleteArticle(item)"
>删除</el-button
>
</div>
</div>
</div>
<!-- 分页组件 -->
<div v-if="filteredArticles.length > 0" class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20, 50]"
:total="filteredArticles.length"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { article } from "@/api/article";
const router = useRouter();
//
const articleData = ref<any[]>([]);
const searchText = ref("");
const loading = ref(false);
//
const currentPage = ref(1);
const pageSize = ref(10);
//
const filteredArticles = computed(() => {
let filtered = articleData.value;
//
if (searchText.value) {
filtered = filtered.filter(
(article: any) =>
article.title.toLowerCase().includes(searchText.value.toLowerCase()) ||
(article.desc || "")
.toLowerCase()
.includes(searchText.value.toLowerCase()) ||
(article.category_name || "")
.toLowerCase()
.includes(searchText.value.toLowerCase()) ||
(article.author || "")
.toLowerCase()
.includes(searchText.value.toLowerCase())
);
}
return filtered;
});
//
const paginatedArticles = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return filteredArticles.value.slice(start, end);
});
//
const handlePageChange = (page: number) => {
currentPage.value = page;
};
const handleSizeChange = (size: number) => {
pageSize.value = size;
currentPage.value = 1; //
};
//
const handleSearch = () => {
//
};
//
const goToPublish = () => {
router.push("/user/profile/article/publish");
};
//
const editArticle = (article: any) => {
router.push({
path: "/user/profile/article/edit",
query: { id: article.id },
});
};
//
const deleteArticle = async (article: any) => {
try {
await ElMessageBox.confirm(
`确定要删除文章"${article.title}"吗?此操作不可恢复。`,
"确认删除",
{
confirmButtonText: "确定删除",
cancelButtonText: "取消",
type: "warning",
}
);
// API
const response = await article.deleteArticle(article.id);
if (response.data.code === 0) {
ElMessage.success("文章删除成功");
await initArticles(); //
//
const totalPages = Math.ceil(articleData.value.length / pageSize.value);
if (currentPage.value > totalPages && totalPages > 0) {
currentPage.value = totalPages;
}
} else {
ElMessage.error(response.data.msg || "文章删除失败");
}
} catch (error) {
// API
if (error !== "cancel") {
ElMessage.error("文章删除失败,请重试");
}
}
};
//
const getUserId = () => {
const userStr = localStorage.getItem("user");
if (userStr) {
try {
const user = JSON.parse(userStr);
return user.uid;
} catch (error) {
console.error("解析用户信息失败:", error);
return null;
}
}
return null;
};
//
const initArticles = async () => {
const uid = getUserId();
if (!uid) {
ElMessage.warning("请先登录");
return;
}
loading.value = true;
try {
const response: any = await article.getUserArticleList(uid);
if (response.data.code === 0) {
// 使API
articleData.value = response.data.data.articles || [];
} else {
ElMessage.error(response.data.msg || "获取文章列表失败");
articleData.value = [];
}
} catch (error) {
console.error("获取文章列表失败:", error);
ElMessage.error("获取文章列表失败,请重试");
articleData.value = [];
} finally {
loading.value = false;
}
};
onMounted(() => {
initArticles();
});
</script>
<style lang="less" scoped>
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
gap: 16px;
.search-box {
width: 300px;
}
}
.article-list {
.article-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
}
.article-card {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 20px;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 16px;
&:hover {
border-color: #1677ff;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.15);
}
.article-content-area {
flex: 1;
min-width: 0; //
.article-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
.article-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0;
flex: 1;
margin-left: 12px;
}
}
.article-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #909399;
.article-category {
color: #1677ff;
font-weight: 500;
}
}
.article-content {
color: #606266;
line-height: 1.6;
margin-bottom: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-clamp: 3;
}
}
.article-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
.el-button {
width: 80px;
justify-content: center;
}
}
}
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
i {
font-size: 48px;
margin-bottom: 16px;
display: block;
}
p {
font-size: 14px;
margin: 0;
}
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 32px;
padding: 20px 0;
.el-pagination {
::v-deep(.el-pager li) {
min-width: 36px;
height: 36px;
line-height: 36px;
border-radius: 6px;
margin: 0 2px;
}
::v-deep(.el-pager li.is-active) {
background-color: #1677ff;
color: white;
}
::v-deep(.btn-prev, .btn-next) {
min-width: 36px;
height: 36px;
border-radius: 6px;
}
}
}
</style>

View File

@ -0,0 +1,434 @@
<template>
<div class="content-section">
<div class="content-header">
<h2 class="content-title">资源管理</h2>
<p class="content-desc">管理您的应用资源</p>
</div>
<div class="content-body">
<!-- 操作栏 -->
<div class="action-bar">
<el-button type="primary" @click="goToPublish">
<i class="fa-solid fa-plus"></i>
发布资源
</el-button>
<div class="filters">
<el-input
v-model="searchText"
placeholder="搜索资源..."
clearable
@input="handleSearch"
style="width: 300px; margin-left: 12px"
>
<template #prefix>
<i class="fa-solid fa-search"></i>
</template>
</el-input>
</div>
</div>
<!-- 资源列表 -->
<div class="resource-list">
<div v-if="filteredResources.length === 0" class="empty-state">
<i class="fa-solid fa-cubes"></i>
<p>
{{ searchText || filterType ? "未找到相关资源" : "暂无资源" }}
</p>
</div>
<div v-else class="resource-grid">
<div
v-for="resource in paginatedResources"
:key="resource.id"
class="resource-card"
>
<div class="resource-content-area">
<div class="resource-header">
<el-tag :type="resource.type" size="small">
{{ resource.category_name }}
</el-tag>
<h3 class="resource-title">
{{ resource.title }}
</h3>
</div>
<div class="resource-meta">
<span class="resource-version"
>上传者{{ resource.uploader }}
</span>
<span class="resource-date">
{{ resource.create_time }}
</span>
<span class="resource-views">
{{ resource.views }} 阅读
</span>
<span class="resource-downloads">
{{ resource.downloads }} 下载
</span>
</div>
<div class="resource-description">
{{ resource.description }}
</div>
</div>
<div class="resource-actions">
<el-button size="small" @click="editResource(resource)"
>编辑</el-button
>
<el-button
size="small"
type="danger"
@click="deleteResource(resource)"
>删除</el-button
>
</div>
</div>
</div>
<!-- 分页组件 -->
<div v-if="filteredResources.length > 0" class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20, 50]"
:total="totalResources"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { resource } from "@/api/resource";
const router = useRouter();
//
const resourceData = ref<any[]>([]);
const searchText = ref("");
const filterType = ref("");
const loading = ref(false);
//
const currentPage = ref(1);
const pageSize = ref(10);
const totalResources = computed(() => filteredResources.value.length);
//
const filteredResources = computed(() => {
let filtered = resourceData.value;
//
if (searchText.value) {
filtered = filtered.filter(
(resource: any) =>
resource.name.toLowerCase().includes(searchText.value.toLowerCase()) ||
(resource.description || "")
.toLowerCase()
.includes(searchText.value.toLowerCase()) ||
getTypeText(resource.type)
.toLowerCase()
.includes(searchText.value.toLowerCase())
);
}
//
if (filterType.value) {
filtered = filtered.filter(
(resource: any) => resource.type === filterType.value
);
}
return filtered;
});
//
const paginatedResources = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return filteredResources.value.slice(start, end);
});
//
const handlePageChange = (page: number) => {
currentPage.value = page;
};
const handleSizeChange = (size: number) => {
pageSize.value = size;
currentPage.value = 1; //
};
//
const handleSearch = () => {
//
};
//
const getTypeText = (type: string) => {
const typeMap: { [key: string]: string } = {
android: "Android",
ios: "iOS",
web: "Web",
desktop: "桌面应用",
other: "其他",
};
return typeMap[type] || type;
};
//
const getTypeColor = (type: string): string | undefined => {
const colorMap: { [key: string]: string } = {
android: "success",
ios: "primary",
web: "info",
desktop: "warning",
// 'other'使
};
return colorMap[type]; // ElTag使
};
//
const goToPublish = () => {
router.push("/user/profile/resource/publish");
};
//
const goToList = () => {
router.push("/user/profile/resource/list");
};
//
const editResource = (resource: any) => {
router.push({
path: "/user/profile/resource/edit",
query: { id: resource.id },
});
};
//
const deleteResource = async (resource: any) => {
try {
await ElMessageBox.confirm(
`确定要删除资源"${resource.name}"吗?此操作不可恢复。`,
"确认删除",
{
confirmButtonText: "确定删除",
cancelButtonText: "取消",
type: "warning",
}
);
// API
const response = await resource.deleteResource(resource.id);
if (response.data.code === 0) {
ElMessage.success("资源删除成功");
await initResources(); //
//
const totalPages = Math.ceil(resourceData.value.length / pageSize.value);
if (currentPage.value > totalPages && totalPages > 0) {
currentPage.value = totalPages;
}
} else {
ElMessage.error(response.data.msg || "资源删除失败");
}
} catch (error) {
// API
if (error !== "cancel") {
ElMessage.error("资源删除失败,请重试");
}
}
};
//
const getUserId = () => {
const userStr = localStorage.getItem("user");
if (userStr) {
try {
const user = JSON.parse(userStr);
return user.uid;
} catch (error) {
console.error("解析用户信息失败:", error);
return null;
}
}
return null;
};
//
const initResources = async () => {
const uid = getUserId();
if (!uid) {
ElMessage.warning("请先登录");
return;
}
loading.value = true;
try {
const response: any = await resource.getUserResourceList(uid);
if (response.data.code === 0) {
// 使API
resourceData.value = response.data.data.resources || [];
} else {
ElMessage.error(response.data.msg || "获取文章列表失败");
resourceData.value = [];
}
} catch (error) {
console.error("获取文章列表失败:", error);
ElMessage.error("获取文章列表失败,请重试");
resourceData.value = [];
} finally {
loading.value = false;
}
};
onMounted(() => {
initResources();
});
</script>
<style lang="less" scoped>
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
gap: 16px;
.filters {
display: flex;
align-items: center;
gap: 12px;
}
}
.resource-list {
.resource-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
}
.resource-card {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 20px;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 16px;
&:hover {
border-color: #1677ff;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.15);
}
.resource-content-area {
flex: 1;
min-width: 0;
.resource-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
.resource-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0;
flex: 1;
margin-left: 12px;
}
}
.resource-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #909399;
}
.resource-description {
color: #606266;
line-height: 1.6;
margin-bottom: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-clamp: 3;
}
}
.resource-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
.el-button {
width: 80px;
justify-content: center;
}
}
}
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
i {
font-size: 48px;
margin-bottom: 16px;
display: block;
}
p {
font-size: 14px;
margin: 0;
}
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 32px;
padding: 20px 0;
.el-pagination {
::v-deep(.el-pager li) {
min-width: 36px;
height: 36px;
line-height: 36px;
border-radius: 6px;
margin: 0 2px;
}
::v-deep(.el-pager li.is-active) {
background-color: #1677ff;
color: white;
}
::v-deep(.btn-prev, .btn-next) {
min-width: 36px;
height: 36px;
border-radius: 6px;
}
}
}
</style>

View File

@ -1,49 +1,49 @@
<template> <template>
<div class="content-section"> <div class="content-section">
<div class="content-header"> <div class="content-header">
<h2 class="content-title">我的消息</h2> <h2 class="content-title">我的消息</h2>
<p class="content-desc">查看系统消息和通知</p> <p class="content-desc">查看系统消息和通知</p>
</div>
<div class="content-body">
<div v-if="messages.length === 0" class="empty-state">
<i class="fa-solid fa-message"></i>
<p>暂无消息</p>
</div>
<div v-else class="message-list">
<div
v-for="message in messages"
:key="message.id"
class="message-item"
:class="{ 'unread': !message.read }"
@click="markAsRead(message.id)"
>
<div class="message-icon">
<i :class="getMessageIcon(message.type)"></i>
</div>
<div class="message-content">
<div class="message-header">
<h4 class="message-title">{{ message.title }}</h4>
<span class="message-time">{{ message.time }}</span>
</div>
<p class="message-text">{{ message.content }}</p>
</div>
<div v-if="!message.read" class="unread-dot"></div>
</div>
</div>
</div>
</div> </div>
<div class="content-body">
<div v-if="messages.length === 0" class="empty-state">
<i class="fa-solid fa-message"></i>
<p>暂无消息</p>
</div>
<div v-else class="message-list">
<div
v-for="message in messages"
:key="message.id"
class="message-item"
:class="{ unread: !message.read }"
@click="markAsRead(message.id)"
>
<div class="message-icon">
<i :class="getMessageIcon(message.type)"></i>
</div>
<div class="message-content">
<div class="message-header">
<h4 class="message-title">{{ message.title }}</h4>
<span class="message-time">{{ message.time }}</span>
</div>
<p class="message-text">{{ message.content }}</p>
</div>
<div v-if="!message.read" class="unread-dot"></div>
</div>
</div>
</div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
interface Message { interface Message {
id: string; id: string;
title: string; title: string;
content: string; content: string;
time: string; time: string;
type: 'system' | 'notification' | 'warning'; type: "system" | "notification" | "warning";
read: boolean; read: boolean;
} }
// //
@ -51,135 +51,139 @@ const messages = ref<Message[]>([]);
// //
const getMessageIcon = (type: string) => { const getMessageIcon = (type: string) => {
switch (type) { switch (type) {
case 'system': case "system":
return 'fa-solid fa-cog'; return "fa-solid fa-cog";
case 'notification': case "notification":
return 'fa-solid fa-bell'; return "fa-solid fa-bell";
case 'warning': case "warning":
return 'fa-solid fa-exclamation-triangle'; return "fa-solid fa-exclamation-triangle";
default: default:
return 'fa-solid fa-info'; return "fa-solid fa-info";
} }
}; };
// //
const markAsRead = (messageId: string) => { const markAsRead = (messageId: string) => {
const message = messages.value.find(m => m.id === messageId); const message = messages.value.find((m) => m.id === messageId);
if (message) { if (message) {
message.read = true; message.read = true;
} }
}; };
// //
const initMessages = () => { const initMessages = () => {
// API // API
messages.value = []; messages.value = [];
}; };
onMounted(() => { onMounted(() => {
initMessages(); initMessages();
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.message-list { .message-list {
.message-item { .message-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
padding: 16px; padding: 16px;
border: 1px solid #f0f0f0; border: 1px solid #f0f0f0;
border-radius: 8px; border-radius: 8px;
margin-bottom: 12px; margin-bottom: 12px;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
position: relative; position: relative;
&:hover { &:hover {
border-color: #1677ff; border-color: #1677ff;
background: #f8f9ff; background: #f8f9ff;
}
&.unread {
border-left: 4px solid #1677ff;
background: #f0f8ff;
}
.message-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: #1677ff;
color: white;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
i {
font-size: 16px;
}
}
.message-content {
flex: 1;
.message-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
.message-title {
font-size: 16px;
color: #333;
margin: 0;
font-weight: 500;
}
.message-time {
font-size: 12px;
color: #999;
white-space: nowrap;
margin-left: 12px;
}
}
.message-text {
font-size: 14px;
color: #666;
margin: 0;
line-height: 1.5;
}
}
.unread-dot {
width: 8px;
height: 8px;
background: #1677ff;
border-radius: 50%;
position: absolute;
top: 16px;
right: 16px;
}
} }
&.unread {
border-left: 4px solid #1677ff;
background: #f0f8ff;
}
.message-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: #1677ff;
color: white;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
i {
font-size: 16px;
}
}
.message-content {
flex: 1;
.message-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
.message-title {
font-size: 16px;
color: #333;
margin: 0;
font-weight: 500;
}
.message-time {
font-size: 12px;
color: #999;
white-space: nowrap;
margin-left: 12px;
}
}
.message-text {
font-size: 14px;
color: #666;
margin: 0;
line-height: 1.5;
}
}
.unread-dot {
width: 8px;
height: 8px;
background: #1677ff;
border-radius: 50%;
position: absolute;
top: 16px;
right: 16px;
}
}
} }
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 60px 20px; padding: 60px 20px;
color: #999; color: #999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
i { i {
font-size: 48px; font-size: 48px;
margin-bottom: 16px; margin-bottom: 16px;
display: block; display: block;
} }
p { p {
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
} }
} }
</style> </style>

View File

@ -1,685 +0,0 @@
<template>
<div class="content-section">
<div class="content-header">
<h2 class="content-title">资源管理</h2>
<p class="content-desc">管理您的应用资源</p>
</div>
<div class="content-body">
<!-- 操作栏 -->
<div class="action-bar">
<el-button type="primary" @click="showPublishDialog = true">
<i class="fa-solid fa-plus"></i>
发布资源
</el-button>
<div class="filters">
<el-input
v-model="searchText"
placeholder="搜索资源..."
clearable
@input="handleSearch"
style="width: 300px; margin-left: 12px;"
>
<template #prefix>
<i class="fa-solid fa-search"></i>
</template>
</el-input>
</div>
</div>
<!-- 资源列表 -->
<div class="resource-list">
<div v-if="filteredResources.length === 0" class="empty-state">
<i class="fa-solid fa-cubes"></i>
<p>{{ searchText || filterType ? '未找到相关资源' : '暂无资源' }}</p>
</div>
<div v-else class="resource-grid">
<div
v-for="resource in paginatedResources"
:key="resource.id"
class="resource-card"
>
<div class="resource-content-area">
<div class="resource-header">
<el-tag :type="getTypeColor(resource.type)" size="small">
{{ getTypeText(resource.type) }}
</el-tag>
<h3 class="resource-title">
{{ resource.name }}
</h3>
</div>
<div class="resource-meta">
<span class="resource-category">{{ resource.category.join(', ') }}</span>
<span class="resource-downloads">{{ resource.downloads }} 下载</span>
<span class="resource-date">{{ resource.createTime }}</span>
</div>
<div class="resource-description">
{{ resource.description }}
</div>
</div>
<div class="resource-actions">
<el-button size="small" @click="editResource(resource)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteResource(resource)">删除</el-button>
</div>
</div>
</div>
<!-- 分页组件 -->
<div v-if="filteredResources.length > 0" class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 20, 50]"
:total="totalResources"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</div>
<!-- 发布/编辑资源弹窗 -->
<el-dialog
v-model="showPublishDialog"
:title="isEditMode ? '编辑资源' : '发布资源'"
width="800px"
:close-on-click-modal="false"
>
<el-form :model="resourceForm" label-width="80px" :rules="formRules" ref="formRef">
<el-form-item label="资源名称" prop="name" required>
<el-input
v-model="resourceForm.name"
placeholder="请输入资源名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="资源类型" prop="type" required>
<el-select v-model="resourceForm.type" placeholder="请选择资源类型">
<el-option
v-for="type in resourceTypes"
:key="type.value"
:label="type.label"
:value="type.value"
/>
</el-select>
</el-form-item>
<el-form-item label="分类" prop="category" required>
<el-checkbox-group v-model="resourceForm.category">
<el-checkbox
v-for="platform in platforms"
:key="platform.value"
:label="platform.value"
>
{{ platform.label }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="版本号">
<el-input
v-model="resourceForm.version"
placeholder="例如1.0.0"
/>
</el-form-item>
<el-form-item label="下载链接" prop="downloadUrl" required>
<el-input
v-model="resourceForm.downloadUrl"
placeholder="请输入下载链接"
/>
</el-form-item>
<el-form-item label="资源描述" prop="description" required>
<el-input
v-model="resourceForm.description"
type="textarea"
:rows="4"
placeholder="请输入资源描述..."
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="截图上传">
<el-upload
ref="uploadRef"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
action=""
:auto-upload="false"
multiple
accept="image/*"
list-type="picture-card"
>
<el-icon><Plus /></el-icon>
<div class="upload-text">上传截图</div>
</el-upload>
<div class="upload-tips">
<p>支持 JPGPNGGIF 格式单张图片不超过 2MB最多上传 5 </p>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showPublishDialog = false">取消</el-button>
<el-button type="primary" @click="submitResource" :loading="loading">
{{ isEditMode ? '保存修改' : '发布资源' }}
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
interface ResourceForm {
name: string;
type: string;
category: string[];
version: string;
downloadUrl: string;
description: string;
}
interface Resource {
id: string;
name: string;
type: string;
category: string[];
version: string;
downloadUrl: string;
description: string;
createTime: string;
downloads: number;
}
interface ResourceType {
value: string;
label: string;
}
interface Platform {
value: string;
label: string;
}
//
const showPublishDialog = ref(false);
const isEditMode = ref(false);
const searchText = ref('');
const filterType = ref('');
const loading = ref(false);
const formRef = ref();
const fileList = ref([]);
//
const currentPage = ref(1);
const pageSize = ref(10);
const totalResources = computed(() => filteredResources.value.length);
//
const resourceForm = ref<ResourceForm>({
name: '',
type: '',
category: [],
version: '',
downloadUrl: '',
description: ''
});
//
const formRules = {
name: [
{ required: true, message: '请输入资源名称', trigger: 'blur' },
{ min: 1, max: 50, message: '名称长度在 1 到 50 个字符', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择资源类型', trigger: 'change' }
],
category: [
{ type: 'array', required: true, message: '请至少选择一个分类', trigger: 'change' }
],
downloadUrl: [
{ required: true, message: '请输入下载链接', trigger: 'blur' },
{ type: 'url', message: '请输入正确的URL格式', trigger: 'blur' }
],
description: [
{ required: true, message: '请输入资源描述', trigger: 'blur' },
{ min: 10, message: '描述不能少于10个字符', trigger: 'blur' }
]
};
//
const resources = ref<Resource[]>([
{
id: '1',
name: 'Vue DevTools',
type: 'plugin',
category: ['游戏下载'],
version: '6.5.0',
downloadUrl: 'https://chrome.google.com/webstore/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd',
description: 'Vue.js 官方开发者工具,用于调试 Vue 应用。',
createTime: '2024-01-15',
downloads: 1250
},
{
id: '2',
name: 'GoLand IDE',
type: 'software',
category: ['办公下载'],
version: '2023.3',
downloadUrl: 'https://www.jetbrains.com/go/download/',
description: 'JetBrains 出品的 Go 语言集成开发环境。',
createTime: '2024-01-10',
downloads: 890
}
]);
//
const resourceTypes = ref<ResourceType[]>([
{ value: 'software', label: '软件工具' },
{ value: 'plugin', label: '插件扩展' },
{ value: 'library', label: '代码库' },
{ value: 'template', label: '模板素材' },
{ value: 'other', label: '其他' }
]);
//
const platforms = ref<Platform[]>([
{ value: 'windows', label: 'Windows' },
{ value: 'macos', label: 'macOS' },
{ value: 'linux', label: 'Linux' },
{ value: 'web', label: 'Web' },
{ value: 'mobile', label: '移动端' }
]);
//
const filteredResources = computed(() => {
let filtered = resources.value;
//
if (filterType.value) {
filtered = filtered.filter(resource => resource.type === filterType.value);
}
//
if (searchText.value) {
filtered = filtered.filter(resource =>
resource.name.toLowerCase().includes(searchText.value.toLowerCase()) ||
resource.description.toLowerCase().includes(searchText.value.toLowerCase())
);
}
return filtered;
});
//
const paginatedResources = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return filteredResources.value.slice(start, end);
});
//
const handlePageChange = (page: number) => {
currentPage.value = page;
};
const handleSizeChange = (size: number) => {
pageSize.value = size;
currentPage.value = 1; //
};
//
const getTypeText = (type: string) => {
const typeObj = resourceTypes.value.find(t => t.value === type);
return typeObj ? typeObj.label : type;
};
//
const getTypeColor = (type: string) => {
switch (type) {
case 'software': return 'success';
case 'plugin': return 'primary';
case 'library': return 'warning';
case 'template': return 'info';
default: return '';
}
};
//
const getPlatformText = (platform: string) => {
const platformObj = platforms.value.find(p => p.value === platform);
return platformObj ? platformObj.label : platform;
};
//
const handleSearch = () => {
//
};
//
const handleFilter = () => {
//
};
//
const handleFileChange = (file: any, fileList: any[]) => {
//
if (file.size > 2 * 1024 * 1024) {
ElMessage.error('图片大小不能超过 2MB');
return false;
}
//
if (fileList.length > 5) {
ElMessage.error('最多只能上传 5 张图片');
return false;
}
};
//
const handleFileRemove = (file: any, fileList: any[]) => {
//
};
//
const editResource = (resource: Resource) => {
isEditMode.value = true;
resourceForm.value = {
name: resource.name,
type: resource.type,
category: [...resource.category],
version: resource.version,
downloadUrl: resource.downloadUrl,
description: resource.description
};
showPublishDialog.value = true;
};
//
const downloadResource = (resource: Resource) => {
window.open(resource.downloadUrl, '_blank');
};
//
const deleteResource = async (resource: Resource) => {
try {
await ElMessageBox.confirm(
`确定要删除资源"${resource.name}"吗?此操作不可恢复。`,
'确认删除',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
}
);
// API
const index = resources.value.findIndex(r => r.id === resource.id);
if (index > -1) {
resources.value.splice(index, 1);
//
const totalPages = Math.ceil(resources.value.length / pageSize.value);
if (currentPage.value > totalPages && totalPages > 0) {
currentPage.value = totalPages;
}
ElMessage.success('资源删除成功');
}
} catch (error) {
//
}
};
//
const submitResource = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return;
loading.value = true;
try {
if (isEditMode.value) {
//
ElMessage.success('资源更新成功');
} else {
//
const newResource: Resource = {
id: Date.now().toString(),
name: resourceForm.value.name,
type: resourceForm.value.type,
category: [...resourceForm.value.category],
version: resourceForm.value.version || '1.0.0',
downloadUrl: resourceForm.value.downloadUrl,
description: resourceForm.value.description,
createTime: new Date().toLocaleDateString(),
downloads: 0
};
resources.value.unshift(newResource);
currentPage.value = 1; //
ElMessage.success('资源发布成功');
}
showPublishDialog.value = false;
resetForm();
} catch (error) {
ElMessage.error('操作失败,请重试');
} finally {
loading.value = false;
}
});
};
//
const resetForm = () => {
resourceForm.value = {
name: '',
type: '',
category: [],
version: '',
downloadUrl: '',
description: ''
};
fileList.value = [];
if (formRef.value) {
formRef.value.clearValidate();
}
};
//
const initResources = () => {
// API
};
onMounted(() => {
initResources();
});
</script>
<style lang="less" scoped>
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
gap: 16px;
.filters {
display: flex;
gap: 12px;
align-items: center;
}
}
.resource-list {
.resource-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
}
.resource-card {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 20px;
transition: all 0.3s ease;
display: flex;
align-items: flex-start;
gap: 16px;
&:hover {
border-color: #1677ff;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.15);
}
.resource-content-area {
flex: 1;
min-width: 0; //
.resource-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
.resource-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0;
flex: 1;
margin-left: 12px;
}
}
.resource-meta {
display: flex;
gap: 16px;
margin-bottom: 12px;
font-size: 12px;
color: #909399;
.resource-version {
color: #1677ff;
font-weight: 500;
}
}
.resource-platforms {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.resource-description {
color: #606266;
line-height: 1.6;
margin-bottom: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-clamp: 2;
}
}
.resource-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
.el-button {
width: 80px;
justify-content: center;
}
}
}
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
i {
font-size: 48px;
margin-bottom: 16px;
display: block;
}
p {
font-size: 14px;
margin: 0;
}
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 32px;
padding: 20px 0;
.el-pagination {
::v-deep(.el-pager li) {
min-width: 36px;
height: 36px;
line-height: 36px;
border-radius: 6px;
margin: 0 2px;
}
::v-deep(.el-pager li.is-active) {
background-color: #1677ff;
color: white;
}
::v-deep(.btn-prev, .btn-next) {
min-width: 36px;
height: 36px;
border-radius: 6px;
}
}
}
/* 弹窗表单样式 */
.el-dialog {
::v-deep(.el-dialog__body) {
padding: 20px 24px;
}
.el-form {
.el-form-item {
margin-bottom: 20px;
}
.upload-tips {
margin-top: 8px;
p {
margin: 0;
font-size: 12px;
color: #909399;
}
}
.upload-text {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
}
}
</style>

View File

@ -1,156 +1,168 @@
<template> <template>
<div class="content-section"> <div class="content-section">
<div class="content-header"> <div class="content-header">
<h2 class="content-title">我的钱包</h2> <h2 class="content-title">我的钱包</h2>
<p class="content-desc">查看余额和交易记录</p> <p class="content-desc">查看余额和交易记录</p>
</div>
<div class="content-body">
<div class="wallet-info">
<div class="balance-card">
<div class="balance-title">账户余额</div>
<div class="balance-amount">¥ {{ balance }}</div>
</div>
</div>
<div class="transaction-history">
<h3>交易记录</h3>
<el-divider></el-divider>
<div v-if="transactions.length === 0" class="empty-state">
<i class="fa-solid fa-wallet"></i>
<p>暂无交易记录</p>
</div>
<div v-else class="transaction-list">
<div
v-for="transaction in transactions"
:key="transaction.id"
class="transaction-item"
>
<div class="transaction-info">
<div class="transaction-desc">{{ transaction.description }}</div>
<div class="transaction-time">{{ transaction.time }}</div>
</div>
<div class="transaction-amount" :class="{ 'income': transaction.type === 'income', 'expense': transaction.type === 'expense' }">
{{ transaction.type === 'income' ? '+' : '-' }}¥{{ transaction.amount }}
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div class="content-body">
<div class="wallet-info">
<div class="balance-card">
<div class="balance-title">账户余额</div>
<div class="balance-amount">¥ {{ balance }}</div>
</div>
</div>
<div class="transaction-history">
<h3>交易记录</h3>
<el-divider></el-divider>
<div v-if="transactions.length === 0" class="empty-state">
<i class="fa-solid fa-wallet"></i>
<p>暂无交易记录</p>
</div>
<div v-else class="transaction-list">
<div
v-for="transaction in transactions"
:key="transaction.id"
class="transaction-item"
>
<div class="transaction-info">
<div class="transaction-desc">{{ transaction.description }}</div>
<div class="transaction-time">{{ transaction.time }}</div>
</div>
<div
class="transaction-amount"
:class="{
income: transaction.type === 'income',
expense: transaction.type === 'expense',
}"
>
{{ transaction.type === "income" ? "+" : "-" }}¥{{
transaction.amount
}}
</div>
</div>
</div>
</div>
</div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
interface Transaction { interface Transaction {
id: string; id: string;
description: string; description: string;
amount: string; amount: string;
time: string; time: string;
type: 'income' | 'expense'; type: "income" | "expense";
} }
// //
const balance = ref('0.00'); const balance = ref("0.00");
const transactions = ref<Transaction[]>([]); const transactions = ref<Transaction[]>([]);
// //
const initWalletData = () => { const initWalletData = () => {
// API // API
balance.value = '0.00'; balance.value = "0.00";
transactions.value = []; transactions.value = [];
}; };
onMounted(() => { onMounted(() => {
initWalletData(); initWalletData();
}); });
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.wallet-info { .wallet-info {
margin-bottom: 32px; margin-bottom: 32px;
.balance-card { .balance-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
padding: 24px; padding: 24px;
border-radius: 12px; border-radius: 12px;
text-align: center; text-align: center;
.balance-title { .balance-title {
font-size: 14px; font-size: 14px;
margin-bottom: 8px; margin-bottom: 8px;
opacity: 0.9; opacity: 0.9;
}
.balance-amount {
font-size: 32px;
font-weight: 600;
}
} }
.balance-amount {
font-size: 32px;
font-weight: 600;
}
}
} }
.transaction-history { .transaction-history {
h3 { h3 {
font-size: 18px; font-size: 18px;
color: #333; color: #333;
margin-bottom: 16px; margin-bottom: 16px;
} }
} }
.transaction-list { .transaction-list {
.transaction-item { .transaction-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 16px 0; padding: 16px 0;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
}
.transaction-info {
.transaction-desc {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.transaction-time {
font-size: 12px;
color: #999;
}
}
.transaction-amount {
font-size: 16px;
font-weight: 500;
&.income {
color: #67c23a;
}
&.expense {
color: #f56c6c;
}
}
} }
.transaction-info {
.transaction-desc {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.transaction-time {
font-size: 12px;
color: #999;
}
}
.transaction-amount {
font-size: 16px;
font-weight: 500;
&.income {
color: #67c23a;
}
&.expense {
color: #f56c6c;
}
}
}
} }
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 60px 20px; padding: 60px 20px;
color: #999; color: #999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
i { i {
font-size: 48px; font-size: 48px;
margin-bottom: 16px; margin-bottom: 16px;
display: block; display: block;
} }
p { p {
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
} }
} }
</style> </style>

View File

@ -10,17 +10,6 @@ import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
AutoImport({
imports: ["vue", "vue-router"],
resolvers: [ElementPlusResolver()],
dts: "src/auto-imports.d.ts",
eslintrc: {
enabled: true,
},
}),
Components({
resolvers: [ElementPlusResolver()],
}),
], ],
resolve: { resolve: {
alias: { alias: {
@ -37,4 +26,8 @@ export default defineConfig({
}, },
}, },
}, },
server: {
port: 5000,
host: true, // 允许外部访问
},
}); });