Expand EasyCode front workbench features
This commit is contained in:
@@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<section class="page generate-page">
|
||||
<div class="panel">
|
||||
<div class="panel" v-loading="loadingProject">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h1 class="panel-title">项目生成工作台</h1>
|
||||
<p class="muted">先生成数据库结构,确认后再进入项目预览。</p>
|
||||
</div>
|
||||
<el-steps class="steps" :active="activeStep" finish-status="success" simple>
|
||||
<el-step title="生成数据库" />
|
||||
<el-step title="预览项目" />
|
||||
</el-steps>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
@@ -69,34 +65,45 @@
|
||||
</div>
|
||||
|
||||
<div class="panel designer-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-header database-header">
|
||||
<h2 class="panel-title">数据库设计</h2>
|
||||
<span class="muted">表数量:{{ database.tables.length }}</span>
|
||||
<div class="database-header-actions">
|
||||
<el-radio-group v-model="databaseView" size="large">
|
||||
<el-radio-button label="editor">表结构编辑</el-radio-button>
|
||||
<el-radio-button label="er">ER 图</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span class="muted">表数量:{{ database.tables.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DatabaseDesigner v-if="database.tables.length" v-model="database" />
|
||||
<template v-if="database.tables.length">
|
||||
<DatabaseDesigner v-show="databaseView === 'editor'" v-model="database" />
|
||||
<ErDiagramView v-show="databaseView === 'er'" :tables="database.tables" v-model:draft="database.erDiagram" />
|
||||
</template>
|
||||
<el-empty v-else description="点击生成数据库后,这里会展示可编辑表结构" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DataAnalysis, View } from '@element-plus/icons-vue'
|
||||
import DatabaseDesigner from '@/components/DatabaseDesigner.vue'
|
||||
import { createProject, generateDatabase, generateProject, saveDatabase } from '@/api/project'
|
||||
import ErDiagramView from '@/components/ErDiagramView.vue'
|
||||
import { createProject, generateDatabase, generateProject, getDatabase, getProject, saveDatabase } from '@/api/project'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeStep = ref(0)
|
||||
const generating = ref(false)
|
||||
const saving = ref(false)
|
||||
const previewing = ref(false)
|
||||
const projectId = ref(null)
|
||||
const loadingProject = ref(false)
|
||||
const databaseView = ref('editor')
|
||||
const projectId = ref(normalizeProjectId(route.query.projectId))
|
||||
const errorMessage = ref('')
|
||||
const database = ref({ tables: [], sql: '' })
|
||||
const database = ref({ tables: [], sql: '', erDiagram: emptyErDiagram() })
|
||||
const projectForm = reactive({
|
||||
projectName: '',
|
||||
keyword: String(route.query.keyword || ''),
|
||||
@@ -104,15 +111,69 @@ const projectForm = reactive({
|
||||
industryTemplate: ''
|
||||
})
|
||||
|
||||
const LAST_PROJECT_KEY = 'easycode_last_project_id'
|
||||
|
||||
function unwrap(result) {
|
||||
return result?.data || result || {}
|
||||
}
|
||||
|
||||
function emptyErDiagram() {
|
||||
return {
|
||||
positions: {},
|
||||
relations: [],
|
||||
deletedEdgeIds: []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeErDiagram(value) {
|
||||
const source = value && typeof value === 'object' ? value : {}
|
||||
return {
|
||||
positions: source.positions && typeof source.positions === 'object' ? source.positions : {},
|
||||
relations: Array.isArray(source.relations) ? source.relations : [],
|
||||
deletedEdgeIds: Array.isArray(source.deletedEdgeIds) ? source.deletedEdgeIds : []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDatabase(payload) {
|
||||
const data = unwrap(payload)
|
||||
return {
|
||||
tables: Array.isArray(data.tables) ? data.tables : [],
|
||||
sql: data.sql || ''
|
||||
sql: data.sql || '',
|
||||
erDiagram: normalizeErDiagram(data.erDiagram)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProjectId(value) {
|
||||
const raw = Array.isArray(value) ? value[0] : value
|
||||
const id = Number(raw)
|
||||
return Number.isFinite(id) && id > 0 ? String(id) : ''
|
||||
}
|
||||
|
||||
function rememberProject(id) {
|
||||
if (id) {
|
||||
localStorage.setItem(LAST_PROJECT_KEY, String(id))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectDraft(value) {
|
||||
const id = normalizeProjectId(value)
|
||||
if (!id) return
|
||||
|
||||
loadingProject.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const [projectResult, databaseResult] = await Promise.all([getProject(id), getDatabase(id)])
|
||||
const project = unwrap(projectResult)
|
||||
projectId.value = id
|
||||
projectForm.projectName = project.projectName || ''
|
||||
projectForm.projectDesc = project.projectDesc || ''
|
||||
projectForm.industryTemplate = project.industryTemplate || ''
|
||||
database.value = normalizeDatabase(databaseResult)
|
||||
rememberProject(id)
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || '加载项目草稿失败,请从我的项目重新打开'
|
||||
} finally {
|
||||
loadingProject.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +192,15 @@ async function ensureProject() {
|
||||
throw new Error('创建项目成功但未返回项目 ID')
|
||||
}
|
||||
|
||||
rememberProject(projectId.value)
|
||||
router.replace({
|
||||
path: '/generate',
|
||||
query: {
|
||||
...route.query,
|
||||
projectId: projectId.value
|
||||
}
|
||||
})
|
||||
|
||||
return projectId.value
|
||||
}
|
||||
|
||||
@@ -161,7 +231,6 @@ async function handleGenerateDatabase() {
|
||||
errorMessage.value = '后端暂未返回表结构,请稍后重试或手动添加表。'
|
||||
}
|
||||
|
||||
activeStep.value = 1
|
||||
ElMessage.success('数据库结构生成完成')
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || '生成数据库失败,请确认后端服务已启动。'
|
||||
@@ -196,6 +265,22 @@ async function handlePreview() {
|
||||
previewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.projectId,
|
||||
(value) => {
|
||||
const id = normalizeProjectId(value)
|
||||
if (id && id !== projectId.value) {
|
||||
loadProjectDraft(id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (projectId.value) {
|
||||
loadProjectDraft(projectId.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -204,10 +289,6 @@ async function handlePreview() {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.steps {
|
||||
width: min(430px, 100%);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
@@ -226,10 +307,25 @@ async function handlePreview() {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.database-header {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.database-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.panel-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.database-header-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
<el-button size="large" type="primary" :icon="MagicStick" @click="handleGenerate">
|
||||
立即生成
|
||||
</el-button>
|
||||
<el-button size="large" @click="router.push('/login')">登录</el-button>
|
||||
</el-form>
|
||||
|
||||
<div class="status-strip">
|
||||
@@ -95,7 +94,7 @@ function handleGenerate() {
|
||||
|
||||
.quick-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1fr) auto auto;
|
||||
grid-template-columns: minmax(240px, 1fr) auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
<aside class="structure-pane">
|
||||
<div class="panel-header">
|
||||
<h1 class="panel-title">项目结构</h1>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadStructures">刷新</el-button>
|
||||
<div class="toolbar">
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadStructures">刷新</el-button>
|
||||
<el-button type="primary" :icon="Download" :loading="downloading" @click="downloadSource">下载源码</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-tabs v-model="activeType" class="type-tabs">
|
||||
<el-tab-pane label="后端" name="backend" />
|
||||
@@ -26,16 +29,18 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { Download, Refresh } from '@element-plus/icons-vue'
|
||||
import CodePreview from '@/components/CodePreview.vue'
|
||||
import ProjectStructureTree from '@/components/ProjectStructureTree.vue'
|
||||
import { getFileContent, getProjectStructure } from '@/api/project'
|
||||
import { downloadProject, getFileContent, getProjectStructure } from '@/api/project'
|
||||
import { saveBlob, sourceZipName } from '@/utils/download'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.projectId)
|
||||
const activeType = ref('backend')
|
||||
const loading = ref(false)
|
||||
const codeLoading = ref(false)
|
||||
const downloading = ref(false)
|
||||
const code = ref('')
|
||||
const selectedFileName = ref('')
|
||||
const structures = reactive({
|
||||
@@ -112,6 +117,19 @@ async function handleFileSelect(file) {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSource() {
|
||||
downloading.value = true
|
||||
try {
|
||||
const blob = await downloadProject(projectId.value)
|
||||
saveBlob(blob, sourceZipName({ projectId: projectId.value }))
|
||||
ElMessage.success('源码下载已开始')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '下载源码失败')
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeType, () => {
|
||||
code.value = ''
|
||||
selectedFileName.value = ''
|
||||
|
||||
225
RuoYi-Vue/easycode-web/src/views/ProjectListView.vue
Normal file
225
RuoYi-Vue/easycode-web/src/views/ProjectListView.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<section class="page projects-page">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h1 class="panel-title">我的项目</h1>
|
||||
<p class="muted">继续编辑已创建的项目草稿,或打开已经生成的项目预览。</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadProjects">刷新</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="router.push('/generate')">新建项目</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<el-table v-loading="loading" :data="projects" class="project-table">
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<div class="project-name">{{ row.projectName || '未命名项目' }}</div>
|
||||
<div class="project-desc">{{ row.projectDesc || row.industryTemplate || '暂无描述' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusMeta(row).type" effect="light">
|
||||
{{ statusMeta(row).label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updateTime" label="更新时间" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.updateTime || row.createTime || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="390" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button class="action-button action-button-wide" :icon="EditPen" plain @click="continueProject(row)">
|
||||
继续生成
|
||||
</el-button>
|
||||
<el-button
|
||||
class="action-button"
|
||||
:icon="View"
|
||||
plain
|
||||
:disabled="row.previewStatus !== '1'"
|
||||
@click="router.push(`/project/${projectIdOf(row)}/preview`)"
|
||||
>
|
||||
预览
|
||||
</el-button>
|
||||
<el-button
|
||||
class="action-button"
|
||||
:icon="Download"
|
||||
plain
|
||||
:disabled="row.previewStatus !== '1'"
|
||||
:loading="downloadingProjectId === projectIdOf(row)"
|
||||
@click="downloadSource(row)"
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button class="action-button" :icon="Delete" type="danger" plain @click="removeProject(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty
|
||||
v-if="!loading && !projects.length"
|
||||
description="暂无项目,创建一个新的项目草稿吧"
|
||||
>
|
||||
<el-button type="primary" :icon="Plus" @click="router.push('/generate')">新建项目</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, Download, EditPen, Plus, Refresh, View } from '@element-plus/icons-vue'
|
||||
import { deleteProject, downloadProject, listProjects } from '@/api/project'
|
||||
import { saveBlob, sourceZipName } from '@/utils/download'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const downloadingProjectId = ref(null)
|
||||
const projects = ref([])
|
||||
|
||||
function projectIdOf(project) {
|
||||
return project?.projectId || project?.id
|
||||
}
|
||||
|
||||
function statusMeta(project) {
|
||||
if (project?.previewStatus === '1') {
|
||||
return { label: '已预览', type: 'success' }
|
||||
}
|
||||
if (project?.generateStatus === '1') {
|
||||
return { label: '已生成数据库', type: 'primary' }
|
||||
}
|
||||
return { label: '草稿', type: 'info' }
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listProjects()
|
||||
projects.value = Array.isArray(result) ? result : []
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '加载项目列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function continueProject(project) {
|
||||
const projectId = projectIdOf(project)
|
||||
if (!projectId) {
|
||||
ElMessage.warning('项目 ID 不存在,无法继续')
|
||||
return
|
||||
}
|
||||
router.push({ path: '/generate', query: { projectId } })
|
||||
}
|
||||
|
||||
async function downloadSource(project) {
|
||||
const projectId = projectIdOf(project)
|
||||
if (!projectId) return
|
||||
if (project.previewStatus !== '1') {
|
||||
ElMessage.warning('请先完成项目预览')
|
||||
return
|
||||
}
|
||||
|
||||
downloadingProjectId.value = projectId
|
||||
try {
|
||||
const blob = await downloadProject(projectId)
|
||||
saveBlob(blob, sourceZipName(project))
|
||||
ElMessage.success('源码下载已开始')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '下载源码失败')
|
||||
} finally {
|
||||
downloadingProjectId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProject(project) {
|
||||
const projectId = projectIdOf(project)
|
||||
if (!projectId) return
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除项目「${project.projectName || projectId}」?`, '删除项目', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await deleteProject(projectId)
|
||||
ElMessage.success('项目已删除')
|
||||
await loadProjects()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') {
|
||||
ElMessage.error(error.message || '删除项目失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProjects)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.projects-page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px 20px 22px;
|
||||
}
|
||||
|
||||
.project-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
color: #172033;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-desc {
|
||||
margin-top: 4px;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-actions :deep(.el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
min-width: 72px;
|
||||
height: 32px;
|
||||
padding: 7px 10px;
|
||||
border-color: #d8e0eb;
|
||||
color: #475467;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.action-button-wide {
|
||||
min-width: 94px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.panel-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
403
RuoYi-Vue/easycode-web/src/views/SourceDetailView.vue
Normal file
403
RuoYi-Vue/easycode-web/src/views/SourceDetailView.vue
Normal file
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<section class="page source-detail-page" v-loading="loading">
|
||||
<template v-if="project">
|
||||
<div class="detail-hero">
|
||||
<el-button class="back-button" :icon="Back" plain @click="router.push('/source-store')">返回源码库</el-button>
|
||||
<div class="hero-main">
|
||||
<div class="hero-cover">{{ project.coverText }}</div>
|
||||
<div class="hero-copy">
|
||||
<div class="hero-title-row">
|
||||
<div>
|
||||
<p class="eyebrow">源码详情</p>
|
||||
<h1>{{ project.name }}</h1>
|
||||
</div>
|
||||
<strong>{{ project.price }}</strong>
|
||||
</div>
|
||||
<p class="muted">{{ project.summary }}</p>
|
||||
<div class="tag-row">
|
||||
<el-tag v-for="tag in project.tags" :key="tag" effect="plain">{{ tag }}</el-tag>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<el-button
|
||||
:icon="project.purchased ? Download : ShoppingCart"
|
||||
size="large"
|
||||
type="primary"
|
||||
@click="project.purchased ? handleDownload() : handlePurchase()"
|
||||
>
|
||||
{{ project.purchased ? '下载源码' : '购买源码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-layout">
|
||||
<article class="markdown-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">项目描述</h2>
|
||||
</div>
|
||||
<div class="markdown-body" v-html="descriptionHtml"></div>
|
||||
</article>
|
||||
|
||||
<aside class="summary-panel">
|
||||
<section>
|
||||
<h2>项目信息</h2>
|
||||
<dl class="info-list">
|
||||
<div>
|
||||
<dt>浏览量</dt>
|
||||
<dd>{{ formatViews(project.views) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新时间</dt>
|
||||
<dd>{{ project.updateTime }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>功能模块</h2>
|
||||
<div class="pill-list">
|
||||
<span v-for="module in project.modules" :key="module">{{ module }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>适用场景</h2>
|
||||
<div class="pill-list quiet">
|
||||
<span v-for="scene in project.scenes" :key="scene">{{ scene }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-else-if="!loading" description="未找到源码项目">
|
||||
<el-button type="primary" @click="router.push('/source-store')">返回源码库</el-button>
|
||||
</el-empty>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Back, Download, ShoppingCart } from '@element-plus/icons-vue'
|
||||
import { getSourceDownloadInfo, getSourceProject, purchaseSourceProject } from '@/api/source'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const project = ref(null)
|
||||
const descriptionHtml = computed(() => renderMarkdown(project.value?.descriptionMd || ''))
|
||||
|
||||
onMounted(loadProject)
|
||||
|
||||
async function loadProject() {
|
||||
loading.value = true
|
||||
try {
|
||||
project.value = await getSourceProject(route.params.sourceId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatViews(views) {
|
||||
return `${Number(views || 0).toLocaleString('zh-CN')} 人浏览`
|
||||
}
|
||||
|
||||
async function handlePurchase() {
|
||||
if (!getToken()) {
|
||||
ElMessage.warning('请先登录后购买源码')
|
||||
router.push({ path: '/login', query: { redirect: route.fullPath } })
|
||||
return
|
||||
}
|
||||
await purchaseSourceProject(project.value.projectId)
|
||||
ElMessage.success('购买成功,源码资源已加入你的账户')
|
||||
await loadProject()
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
if (!getToken()) {
|
||||
ElMessage.warning('请先登录后下载源码')
|
||||
router.push({ path: '/login', query: { redirect: route.fullPath } })
|
||||
return
|
||||
}
|
||||
const info = await getSourceDownloadInfo(project.value.projectId)
|
||||
if (info?.resourceUrl) {
|
||||
window.open(info.resourceUrl, '_blank')
|
||||
return
|
||||
}
|
||||
ElMessage.warning('源码资源暂未配置')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.source-detail-page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.detail-hero,
|
||||
.markdown-panel,
|
||||
.summary-panel {
|
||||
border: 1px solid #d9e0ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.hero-main {
|
||||
display: grid;
|
||||
grid-template-columns: 156px minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero-cover {
|
||||
display: grid;
|
||||
min-height: 156px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
color: #1e3a8a;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(20, 184, 166, 0.16)),
|
||||
#f8fafc;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: #1d4ed8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #101828;
|
||||
font-size: 30px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
max-width: 760px;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
|
||||
strong {
|
||||
color: #dc2626;
|
||||
font-size: 22px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-row,
|
||||
.hero-actions,
|
||||
.pill-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.markdown-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
color: #101828;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
padding: 24px;
|
||||
color: #344054;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3),
|
||||
.markdown-body :deep(h4) {
|
||||
margin: 24px 0 12px;
|
||||
color: #101828;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1:first-child),
|
||||
.markdown-body :deep(h2:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1) {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h2) {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p),
|
||||
.markdown-body :deep(ul),
|
||||
.markdown-body :deep(blockquote),
|
||||
.markdown-body :deep(table),
|
||||
.markdown-body :deep(pre) {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.markdown-body :deep(ul) {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre) {
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
color: #dbeafe;
|
||||
background: #172033;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown-body :deep(code) {
|
||||
border-radius: 6px;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre code) {
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdown-body :deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.markdown-body :deep(th),
|
||||
.markdown-body :deep(td) {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-body :deep(blockquote) {
|
||||
padding: 10px 14px;
|
||||
border-left: 4px solid #3b82f6;
|
||||
color: #475467;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
color: #101828;
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-list {
|
||||
margin: 0;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
dt,
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
dd {
|
||||
color: #101828;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.pill-list span {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pill-list.quiet span {
|
||||
color: #344054;
|
||||
background: #f2f4f7;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.hero-main,
|
||||
.detail-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-cover {
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.hero-title-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
345
RuoYi-Vue/easycode-web/src/views/SourceStoreView.vue
Normal file
345
RuoYi-Vue/easycode-web/src/views/SourceStoreView.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<section class="page source-store-page">
|
||||
<div class="store-hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">源码库 / Source Store</p>
|
||||
<h1>精选 Java 源码项目库</h1>
|
||||
<p class="muted">
|
||||
沉淀可交付、可运行、可二开的企业级源码项目,支持按行业场景和技术栈快速筛选。
|
||||
</p>
|
||||
</div>
|
||||
<div class="store-search">
|
||||
<el-input v-model="keyword" size="large" placeholder="搜索源码项目,例如 DMS、商城、OA">
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="store-body">
|
||||
<aside class="category-panel">
|
||||
<h2>源码分类</h2>
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.value"
|
||||
class="category-button"
|
||||
:class="{ active: activeCategory === category.value }"
|
||||
type="button"
|
||||
@click="activeCategory = category.value"
|
||||
>
|
||||
{{ category.label }}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div class="project-area" v-loading="loading">
|
||||
<div class="project-toolbar">
|
||||
<div>
|
||||
<h2>源码项目</h2>
|
||||
<p class="muted">共 {{ filteredProjects.length }} 个可选项目</p>
|
||||
</div>
|
||||
<el-button :icon="RefreshRight" @click="resetFilters">重置筛选</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredProjects.length" class="source-grid">
|
||||
<article v-for="project in filteredProjects" :key="project.id" class="source-card">
|
||||
<div class="source-cover">
|
||||
<span>{{ project.coverText }}</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="source-heading">
|
||||
<div>
|
||||
<h3>{{ project.name }}</h3>
|
||||
<!-- <p>{{ project.summary }}</p> -->
|
||||
</div>
|
||||
<strong>{{ project.price }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="source-tags">
|
||||
<el-tag v-for="tag in project.tags" :key="tag" effect="plain">{{ tag }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="source-meta">
|
||||
<span>{{ formatViews(project.views) }} 人浏览</span>
|
||||
<span>{{ project.updateTime }}</span>
|
||||
</div>
|
||||
|
||||
<div class="source-actions">
|
||||
<el-button :icon="ArrowRight" type="primary" @click="router.push(`/source-store/${project.id}`)">
|
||||
查看详情
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<el-empty v-else-if="!loading" description="暂无匹配的源码项目">
|
||||
<el-button type="primary" @click="resetFilters">重置筛选</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowRight, RefreshRight, Search } from '@element-plus/icons-vue'
|
||||
import { listSourceCategories, listSourceProjects } from '@/api/source'
|
||||
import { buildSourceCategories } from '@/api/sourceAdapter'
|
||||
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const activeCategory = ref('all')
|
||||
const loading = ref(false)
|
||||
const sourceProjects = ref([])
|
||||
const sourceCategories = ref([])
|
||||
|
||||
const categories = computed(() => buildSourceCategories(sourceProjects.value, sourceCategories.value))
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
const normalizedKeyword = keyword.value.trim().toLowerCase()
|
||||
return sourceProjects.value.filter((project) => {
|
||||
const categoryMatched = activeCategory.value === 'all' || project.category === activeCategory.value
|
||||
if (!categoryMatched) return false
|
||||
if (!normalizedKeyword) return true
|
||||
|
||||
const searchText = [
|
||||
project.name,
|
||||
project.summary,
|
||||
...project.tags,
|
||||
...project.modules,
|
||||
...project.scenes
|
||||
].join(' ').toLowerCase()
|
||||
return searchText.includes(normalizedKeyword)
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(loadProjects)
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [projects, categories] = await Promise.all([
|
||||
listSourceProjects(),
|
||||
listSourceCategories().catch(() => [])
|
||||
])
|
||||
sourceProjects.value = projects
|
||||
sourceCategories.value = categories
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatViews(views) {
|
||||
return Number(views || 0).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
keyword.value = ''
|
||||
activeCategory.value = 'all'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.source-store-page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.store-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
|
||||
align-items: end;
|
||||
gap: 24px;
|
||||
padding: 28px;
|
||||
border: 1px solid #d9e0ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: #1d4ed8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #101828;
|
||||
font-size: 32px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
max-width: 620px;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.store-body {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.category-panel {
|
||||
align-self: start;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
border: 1px solid #d9e0ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
color: #172033;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-button {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: #475467;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-button:hover,
|
||||
.category-button.active {
|
||||
border-color: #bfdbfe;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-area {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.project-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #d9e0ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #172033;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.source-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-height: 360px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d9e0ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.source-cover {
|
||||
display: grid;
|
||||
min-height: 142px;
|
||||
place-items: center;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(37, 99, 235, 0.16), rgba(20, 184, 166, 0.14)),
|
||||
#f8fafc;
|
||||
|
||||
span {
|
||||
display: grid;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(37, 99, 235, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #1e3a8a;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
|
||||
.source-content {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.source-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #101828;
|
||||
font-size: 18px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #dc2626;
|
||||
font-size: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.source-tags,
|
||||
.source-meta,
|
||||
.source-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.source-meta {
|
||||
justify-content: space-between;
|
||||
color: #475467;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.source-actions {
|
||||
justify-content: flex-end;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.store-hero,
|
||||
.store-body,
|
||||
.source-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
56
RuoYi-Vue/easycode-web/src/views/sourceStoreLayout.test.mjs
Normal file
56
RuoYi-Vue/easycode-web/src/views/sourceStoreLayout.test.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function readView(name) {
|
||||
return readFileSync(resolve(currentDir, name), 'utf8')
|
||||
}
|
||||
|
||||
test('source store cards use vertical cover-first layout', () => {
|
||||
const source = readView('SourceStoreView.vue')
|
||||
|
||||
assert.match(source, /\.source-card\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);/)
|
||||
assert.doesNotMatch(source, /\.source-card\s*\{[\s\S]*grid-template-columns:\s*112px minmax\(0,\s*1fr\);/)
|
||||
})
|
||||
|
||||
test('source store cards do not actively render project summary', () => {
|
||||
const source = readView('SourceStoreView.vue')
|
||||
const template = source.match(/<template>[\s\S]*<\/template>/)[0]
|
||||
const activeTemplate = template.replace(/<!--[\s\S]*?-->/g, '')
|
||||
|
||||
assert.equal(activeTemplate.includes('project.summary'), false)
|
||||
assert.equal(source.includes('<!-- <p>{{ project.summary }}</p> -->'), true)
|
||||
})
|
||||
|
||||
test('source store loads categories from the front source API', () => {
|
||||
const source = readView('SourceStoreView.vue')
|
||||
|
||||
assert.equal(source.includes('listSourceCategories'), true)
|
||||
assert.match(source, /buildSourceCategories\(sourceProjects\.value,\s*sourceCategories\.value\)/)
|
||||
})
|
||||
|
||||
test('source store and detail do not expose online preview actions', () => {
|
||||
const combinedSource = `${readView('SourceStoreView.vue')}\n${readView('SourceDetailView.vue')}`
|
||||
|
||||
assert.equal(combinedSource.includes('在线预览'), false)
|
||||
assert.equal(combinedSource.includes('handlePreview'), false)
|
||||
})
|
||||
|
||||
test('source detail does not show source size information', () => {
|
||||
const source = readView('SourceDetailView.vue')
|
||||
|
||||
assert.equal(source.includes('源码大小'), false)
|
||||
assert.equal(source.includes('sourceSize'), false)
|
||||
})
|
||||
|
||||
test('source detail keeps purchase as the unauthenticated primary action', () => {
|
||||
const source = readView('SourceDetailView.vue')
|
||||
|
||||
assert.equal(source.includes('购买源码'), true)
|
||||
assert.equal(source.includes('handlePurchase'), true)
|
||||
assert.equal(source.includes('project.purchased ? handleDownload() : handlePurchase()'), true)
|
||||
})
|
||||
Reference in New Issue
Block a user