Expand EasyCode front workbench features

This commit is contained in:
王鹏
2026-05-23 11:31:38 +08:00
parent 56dbc02526
commit 2c8b74ea8a
70 changed files with 6596 additions and 638 deletions

View File

@@ -1 +1,2 @@
VITE_API_BASE_URL=http://localhost:8080
VITE_GENERATE_TIMEOUT=180000

View File

@@ -1 +1,2 @@
VITE_API_BASE_URL=/front
VITE_GENERATE_TIMEOUT=180000

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import request from './request'
import { GENERATE_REQUEST_TIMEOUT } from './timeouts'
function unwrap(res) {
return res && Object.prototype.hasOwnProperty.call(res, 'data') ? res.data : res
@@ -12,10 +13,32 @@ export function createProject(data) {
}).then(unwrap)
}
export function listProjects() {
return request({
url: '/front/project/list',
method: 'get'
}).then(unwrap)
}
export function getProject(projectId) {
return request({
url: `/front/project/${projectId}`,
method: 'get'
}).then(unwrap)
}
export function deleteProject(projectId) {
return request({
url: `/front/project/${projectId}`,
method: 'delete'
}).then(unwrap)
}
export function generateDatabase(projectId, data) {
return request({
url: `/front/project/${projectId}/generate-database`,
method: 'post',
timeout: GENERATE_REQUEST_TIMEOUT,
data
}).then(unwrap)
}
@@ -38,7 +61,8 @@ export function saveDatabase(projectId, data) {
export function generateProject(projectId) {
return request({
url: `/front/project/${projectId}/preview`,
method: 'post'
method: 'post',
timeout: GENERATE_REQUEST_TIMEOUT
}).then(unwrap)
}
@@ -57,3 +81,13 @@ export function getFileContent(projectId, params) {
params
}).then(unwrap)
}
export function downloadProject(projectId, templateType) {
return request({
url: `/front/project/${projectId}/download`,
method: 'get',
params: templateType ? { templateType } : undefined,
responseType: 'blob',
timeout: GENERATE_REQUEST_TIMEOUT
}).then(unwrap)
}

View File

@@ -1,10 +1,11 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { getToken, removeToken } from '@/utils/auth'
import { DEFAULT_REQUEST_TIMEOUT } from './timeouts'
const service = axios.create({
baseURL: '',
timeout: 30000
timeout: DEFAULT_REQUEST_TIMEOUT
})
service.interceptors.request.use((config) => {

View File

@@ -0,0 +1,45 @@
import request from './request'
import { normalizeSourceCategories, normalizeSourceProject, normalizeSourceProjects } from './sourceAdapter'
export function listSourceCategories() {
return request({
url: '/front/source/categories',
method: 'get'
}).then((result) => normalizeSourceCategories(result.data))
}
export function listSourceProjects(params = {}) {
return request({
url: '/front/source/projects',
method: 'get',
params
}).then((result) => normalizeSourceProjects(result.data))
}
export function getSourceProject(slug) {
return request({
url: `/front/source/projects/${slug}`,
method: 'get'
}).then((result) => normalizeSourceProject(result.data))
}
export function purchaseSourceProject(projectId) {
return request({
url: `/front/source/projects/${projectId}/purchase`,
method: 'post'
})
}
export function listSourcePurchases() {
return request({
url: '/front/source/purchases',
method: 'get'
}).then((result) => result.data || [])
}
export function getSourceDownloadInfo(projectId) {
return request({
url: `/front/source/projects/${projectId}/download`,
method: 'get'
}).then((result) => result.data)
}

View File

@@ -0,0 +1,82 @@
export function normalizeSourceProject(project = {}) {
return {
projectId: project.projectId,
id: project.slug || String(project.projectId || ''),
name: project.projectName || project.name || '',
category: project.category || '',
coverText: project.coverText || '',
summary: project.summary || '',
descriptionMd: project.descriptionMd || '',
price: project.priceLabel || formatPrice(project.price),
priceValue: Number(project.price || 0),
views: Number(project.viewCount || project.views || 0),
updateTime: normalizeDate(project.updateTime),
tags: normalizeList(project.tags),
modules: normalizeList(project.modules),
scenes: normalizeList(project.scenes),
purchased: Boolean(project.purchased),
resourceReady: Boolean(project.resourceReady)
}
}
export function normalizeSourceProjects(projects = []) {
return Array.isArray(projects) ? projects.map(normalizeSourceProject) : []
}
export function normalizeSourceCategories(categories = []) {
if (!Array.isArray(categories)) return []
return categories
.map((category) => ({
value: category.value || category.dictValue || '',
label: category.label || category.dictLabel || category.value || category.dictValue || ''
}))
.filter((category) => category.value)
}
export function buildSourceCategories(projects = [], sourceCategories = []) {
const seen = new Set()
const categories = [{ value: 'all', label: '全部源码' }]
normalizeSourceCategories(sourceCategories).forEach((category) => {
if (seen.has(category.value)) return
seen.add(category.value)
categories.push(category)
})
projects.forEach((project) => {
const value = project.category
if (!value || seen.has(value)) return
seen.add(value)
categories.push({
value,
label: value
})
})
return categories
}
function normalizeList(value) {
if (Array.isArray(value)) return value
if (!value) return []
if (typeof value !== 'string') return []
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch (error) {
return value
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean)
}
}
function normalizeDate(value) {
if (!value) return ''
if (typeof value === 'string') return value.slice(0, 10)
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return date.toISOString().slice(0, 10)
}
function formatPrice(cents) {
const value = Number(cents || 0) / 100
return `¥${Number.isInteger(value) ? value : value.toFixed(2)}`
}

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import { buildSourceCategories, normalizeSourceProject } from './sourceAdapter.js'
const backendProject = {
projectId: 10,
slug: 'dms-platform',
projectName: 'Commercial DMS',
category: 'enterprise',
coverText: 'DMS',
summary: 'DMS source',
descriptionMd: '# DMS',
price: 39900,
priceLabel: '¥399',
viewCount: 2860,
updateTime: '2026-05-18 12:00:00',
tags: ['Spring Boot', 'Vue2'],
modules: ['Dealer', 'Repair'],
scenes: ['Aftermarket'],
purchased: true
}
const project = normalizeSourceProject(backendProject)
assert.equal(project.id, 'dms-platform')
assert.equal(project.projectId, 10)
assert.equal(project.name, 'Commercial DMS')
assert.equal(project.price, '¥399')
assert.equal(project.views, 2860)
assert.equal(project.updateTime, '2026-05-18')
assert.equal(project.tags[1], 'Vue2')
assert.equal(project.modules[0], 'Dealer')
assert.equal(project.purchased, true)
const categories = buildSourceCategories(
[
backendProject,
{ ...backendProject, projectId: 11, slug: 'mall', category: 'mall' }
],
[
{ dictValue: 'enterprise', dictLabel: 'Enterprise Systems' },
{ dictValue: 'mall', dictLabel: 'Mall Systems' }
]
)
assert.deepEqual(categories.map((category) => category.value), ['all', 'enterprise', 'mall'])
assert.deepEqual(categories.map((category) => category.label), ['全部源码', 'Enterprise Systems', 'Mall Systems'])

View File

@@ -0,0 +1,9 @@
function toPositiveNumber(value, fallback) {
const number = Number(value)
return Number.isFinite(number) && number > 0 ? number : fallback
}
const env = import.meta.env || {}
export const DEFAULT_REQUEST_TIMEOUT = toPositiveNumber(env.VITE_API_TIMEOUT, 30000)
export const GENERATE_REQUEST_TIMEOUT = toPositiveNumber(env.VITE_GENERATE_TIMEOUT, 180000)

View File

@@ -8,6 +8,8 @@
<nav class="nav">
<router-link to="/">工作台</router-link>
<router-link to="/generate">生成项目</router-link>
<router-link to="/source-store">源码库</router-link>
<router-link to="/projects">我的项目</router-link>
</nav>
<div class="actions">

View File

@@ -323,7 +323,8 @@ function removeField(index) {
.delete-col {
display: flex;
align-items: end;
align-items: flex-start;
padding-top: 30px;
}
.field-head {
@@ -347,5 +348,9 @@ function removeField(index) {
border-right: 0;
border-bottom: 1px solid #e3e8f0;
}
.delete-col {
padding-top: 0;
}
}
</style>

View File

@@ -0,0 +1,514 @@
<template>
<div class="er-diagram">
<el-empty v-if="!graph.nodes.length" description="生成数据库后可查看 ER 图" />
<template v-else>
<div class="er-toolbar">
<div class="relation-builder">
<el-select v-model="relationForm.source" placeholder="来源表">
<el-option v-for="node in graph.nodes" :key="node.id" :label="node.title" :value="node.id" />
</el-select>
<el-select v-model="relationForm.sourceField" placeholder="来源字段">
<el-option v-for="field in sourceFields" :key="field.id" :label="field.name" :value="field.name" />
</el-select>
<span class="relation-arrow"></span>
<el-select v-model="relationForm.target" placeholder="目标表">
<el-option v-for="node in graph.nodes" :key="node.id" :label="node.title" :value="node.id" />
</el-select>
<el-select v-model="relationForm.targetField" placeholder="目标字段">
<el-option v-for="field in targetFields" :key="field.id" :label="field.name" :value="field.name" />
</el-select>
<el-button type="primary" :icon="Plus" @click="addRelation">添加关系</el-button>
</div>
<el-button :icon="Picture" @click="exportPng">导出图片</el-button>
</div>
<div class="er-scroll">
<svg ref="svgRef" class="er-canvas" :viewBox="`0 0 ${canvas.width} ${canvas.height}`" :style="canvasStyle">
<defs>
<marker
id="er-arrow"
markerWidth="10"
markerHeight="10"
refX="8"
refY="3"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L8,3 z" fill="#8aa0bc" />
</marker>
</defs>
<g class="edge-layer">
<g v-for="edge in edgeViews" :key="edge.id">
<path class="relation-line" :class="{ manual: !edge.inferred }" :d="edge.path" marker-end="url(#er-arrow)" />
<text class="relation-label" :x="edge.labelX" :y="edge.labelY">{{ edge.fieldName }}</text>
<g class="edge-delete" :transform="`translate(${edge.labelX + 44}, ${edge.labelY - 13})`" @click="removeRelation(edge)">
<circle r="9" />
<path d="M-4,-4 L4,4 M4,-4 L-4,4" />
</g>
</g>
</g>
<g class="node-layer">
<foreignObject
v-for="node in graph.nodes"
:key="node.id"
:x="node.x"
:y="node.y"
:width="node.width"
:height="node.height"
>
<div xmlns="http://www.w3.org/1999/xhtml" class="entity-node" @pointerdown="startDrag($event, node)">
<div class="entity-head">
<strong>{{ node.title }}</strong>
<span v-if="node.comment">{{ node.comment }}</span>
</div>
<div class="field-list">
<div v-for="field in node.fields" :key="field.id" class="field-row">
<span v-if="field.primary" class="field-badge primary">PK</span>
<span v-else-if="field.required" class="field-badge required">*</span>
<span v-else class="field-badge muted">-</span>
<span class="field-name">{{ field.name }}</span>
<small v-if="field.comment">{{ field.comment }}</small>
</div>
<div v-if="!node.fields.length" class="field-empty">暂无字段</div>
</div>
</div>
</foreignObject>
</g>
</svg>
</div>
</template>
</div>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Picture, Plus } from '@element-plus/icons-vue'
import { buildErExportSvg } from '@/utils/erExport'
import { buildErGraph } from '@/utils/erGraph'
import { saveBlob } from '@/utils/download'
const FIELD_HEIGHT = 28
const HEADER_HEIGHT = 60
const props = defineProps({
tables: {
type: Array,
default: () => []
},
draft: {
type: Object,
default: () => ({ positions: {}, relations: [], deletedEdgeIds: [] })
}
})
const emit = defineEmits(['update:draft'])
const svgRef = ref(null)
const relationForm = reactive({
source: '',
sourceField: '',
target: '',
targetField: ''
})
const dragging = ref(null)
const draftValue = computed(() => normalizeDraft(props.draft))
const graph = computed(() => buildErGraph(props.tables, draftValue.value))
const nodeMap = computed(() => {
const map = new Map()
graph.value.nodes.forEach((node) => map.set(node.id, node))
return map
})
const canvas = computed(() => {
const nodes = graph.value.nodes
const width = Math.max(900, ...nodes.map((node) => node.x + node.width + 48))
const height = Math.max(480, ...nodes.map((node) => node.y + node.height + 48))
return { width, height }
})
const canvasStyle = computed(() => ({
width: `${canvas.value.width}px`,
height: `${canvas.value.height}px`
}))
const sourceFields = computed(() => fieldsOf(relationForm.source))
const targetFields = computed(() => fieldsOf(relationForm.target))
watch(
() => graph.value.nodes.map((node) => node.id).join(','),
() => ensureRelationDefaults(),
{ immediate: true }
)
watch(
() => relationForm.source,
() => {
relationForm.sourceField = sourceFields.value[0]?.name || ''
}
)
watch(
() => relationForm.target,
() => {
relationForm.targetField = targetFields.value[0]?.name || ''
}
)
function normalizeDraft(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 emitDraft(patch) {
emit('update:draft', {
...draftValue.value,
...patch
})
}
function fieldsOf(nodeId) {
return nodeMap.value.get(nodeId)?.fields || []
}
function ensureRelationDefaults() {
const nodes = graph.value.nodes
if (!relationForm.source && nodes[0]) {
relationForm.source = nodes[0].id
}
if (!relationForm.target && nodes[1]) {
relationForm.target = nodes[1].id
}
if (!relationForm.target && nodes[0]) {
relationForm.target = nodes[0].id
}
if (!relationForm.sourceField) {
relationForm.sourceField = sourceFields.value[0]?.name || ''
}
if (!relationForm.targetField) {
relationForm.targetField = targetFields.value[0]?.name || ''
}
}
function fieldY(node, fieldName) {
const index = Math.max(0, node.fields.findIndex((field) => field.name === fieldName))
return node.y + HEADER_HEIGHT + index * FIELD_HEIGHT + FIELD_HEIGHT / 2
}
function edgeAnchors(edge) {
const source = nodeMap.value.get(edge.source)
const target = nodeMap.value.get(edge.target)
if (!source || !target) return null
const sourceOnLeft = source.x <= target.x
const sourceX = sourceOnLeft ? source.x + source.width : source.x
const targetX = sourceOnLeft ? target.x : target.x + target.width
return {
sourceX,
sourceY: fieldY(source, edge.fieldName),
targetX,
targetY: fieldY(target, edge.targetFieldName),
curve: Math.max(56, Math.abs(targetX - sourceX) / 2)
}
}
function addRelation() {
if (!relationForm.source || !relationForm.target || !relationForm.sourceField || !relationForm.targetField) {
ElMessage.warning('请选择关系两端的表和字段')
return
}
if (relationForm.source === relationForm.target) {
ElMessage.warning('关系两端不能是同一张表')
return
}
const relation = {
id: `manual_${Date.now()}_${Math.random().toString(16).slice(2)}`,
source: relationForm.source,
target: relationForm.target,
fieldName: relationForm.sourceField,
targetFieldName: relationForm.targetField
}
emitDraft({ relations: [...draftValue.value.relations, relation] })
}
function removeRelation(edge) {
if (edge.inferred) {
emitDraft({ deletedEdgeIds: Array.from(new Set([...draftValue.value.deletedEdgeIds, edge.id])) })
return
}
emitDraft({ relations: draftValue.value.relations.filter((relation) => relation.id !== edge.id) })
}
function startDrag(event, node) {
dragging.value = {
id: node.id,
startX: event.clientX,
startY: event.clientY,
x: node.x,
y: node.y
}
event.currentTarget.setPointerCapture?.(event.pointerId)
window.addEventListener('pointermove', handleDrag)
window.addEventListener('pointerup', stopDrag, { once: true })
}
function handleDrag(event) {
if (!dragging.value) return
const nextX = Math.max(0, Math.round(dragging.value.x + event.clientX - dragging.value.startX))
const nextY = Math.max(0, Math.round(dragging.value.y + event.clientY - dragging.value.startY))
emitDraft({
positions: {
...draftValue.value.positions,
[dragging.value.id]: { x: nextX, y: nextY }
}
})
}
function stopDrag() {
dragging.value = null
window.removeEventListener('pointermove', handleDrag)
}
async function exportPng() {
const svgText = buildErExportSvg({
width: canvas.value.width,
height: canvas.value.height,
nodes: graph.value.nodes,
edges: edgeViews.value
})
const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(svgBlob)
const image = new Image()
image.decoding = 'async'
try {
await new Promise((resolve, reject) => {
image.onload = resolve
image.onerror = reject
image.src = url
})
const canvasElement = document.createElement('canvas')
canvasElement.width = canvas.value.width
canvasElement.height = canvas.value.height
const context = canvasElement.getContext('2d')
context.fillStyle = '#f8fafc'
context.fillRect(0, 0, canvasElement.width, canvasElement.height)
context.drawImage(image, 0, 0)
const blob = await new Promise((resolve) => canvasElement.toBlob(resolve, 'image/png'))
if (blob) {
saveBlob(blob, 'er-diagram.png')
}
} finally {
URL.revokeObjectURL(url)
}
}
const edgeViews = computed(() => graph.value.edges
.map((edge) => {
const anchors = edgeAnchors(edge)
if (!anchors) return null
const direction = anchors.sourceX <= anchors.targetX ? 1 : -1
const c1x = anchors.sourceX + anchors.curve * direction
const c2x = anchors.targetX - anchors.curve * direction
return {
...edge,
path: `M ${anchors.sourceX} ${anchors.sourceY} C ${c1x} ${anchors.sourceY}, ${c2x} ${anchors.targetY}, ${anchors.targetX} ${anchors.targetY}`,
labelX: (anchors.sourceX + anchors.targetX) / 2,
labelY: (anchors.sourceY + anchors.targetY) / 2 - 8
}
})
.filter(Boolean))
</script>
<style scoped lang="scss">
.er-diagram {
min-height: 430px;
background: #f8fafc;
}
.er-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
border-bottom: 1px solid #e3e8f0;
background: #ffffff;
}
.relation-builder {
display: grid;
grid-template-columns: minmax(120px, 170px) minmax(120px, 170px) auto minmax(120px, 170px) minmax(120px, 170px) auto;
align-items: center;
gap: 10px;
}
.relation-arrow {
color: #64748b;
font-weight: 700;
}
.er-scroll {
overflow: auto;
min-height: 430px;
padding: 24px;
}
.er-canvas {
display: block;
}
.relation-line {
fill: none;
stroke: #8aa0bc;
stroke-width: 1.6;
stroke-dasharray: 6 5;
}
.relation-line.manual {
stroke: #2563eb;
stroke-dasharray: none;
}
.edge-delete {
cursor: pointer;
circle {
fill: #ffffff;
stroke: #e2e8f0;
}
path {
stroke: #ef4444;
stroke-width: 1.8;
stroke-linecap: round;
}
}
.relation-label {
fill: #64748b;
font-size: 12px;
paint-order: stroke;
stroke: #f8fafc;
stroke-width: 4px;
text-anchor: middle;
}
.entity-node {
overflow: hidden;
height: 100%;
border: 1px solid #d6e0ee;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
cursor: move;
user-select: none;
}
.entity-head {
display: grid;
gap: 4px;
padding: 12px 14px;
border-bottom: 1px solid #e7edf5;
background: #eef5ff;
strong {
overflow: hidden;
color: #0f172a;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
overflow: hidden;
color: #64748b;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.field-list {
padding: 8px 10px 10px;
}
.field-row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) minmax(0, 0.9fr);
align-items: center;
gap: 6px;
min-height: 28px;
color: #334155;
font-size: 13px;
small {
overflow: hidden;
color: #94a3b8;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.field-badge {
display: inline-grid;
min-width: 26px;
height: 18px;
place-items: center;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
}
.field-badge.primary {
color: #1d4ed8;
background: #dbeafe;
}
.field-badge.required {
color: #b45309;
background: #fef3c7;
}
.field-badge.muted {
color: #cbd5e1;
}
.field-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.field-empty {
padding: 18px 0;
color: #94a3b8;
text-align: center;
}
@media (max-width: 1080px) {
.er-toolbar {
align-items: flex-start;
flex-direction: column;
}
.relation-builder {
grid-template-columns: minmax(140px, 1fr) minmax(140px, 1fr);
width: 100%;
}
.relation-arrow {
display: none;
}
}
</style>

View File

@@ -0,0 +1,217 @@
export const sourceCategories = [
{ value: 'enterprise', label: '企业管理系统' },
{ value: 'mall', label: '商城系统' },
{ value: 'office', label: 'OA 办公' },
{ value: 'lowcode', label: '低代码平台' },
{ value: 'microservice', label: '微服务脚手架' }
]
export const sourceProjects = [
{
id: 'dms-platform',
name: '商用车 DMS 管理系统',
category: 'enterprise',
coverText: 'DMS',
summary: '覆盖经销商、维修工单、索赔、配件库存、服务活动和结算的商用车售后管理源码。',
price: '¥399',
views: 2860,
updateTime: '2026-05-18',
tags: ['Spring Boot', 'Vue2', 'MySQL'],
modules: ['经销商管理', '维修工单', '配件库存', '索赔结算', '服务活动'],
scenes: ['汽车后市场', '商用车售后', '经销商协同'],
descriptionMd: `# 商用车 DMS 管理系统
这套源码面向商用车售后服务场景,提供经销商、车辆档案、维修工单、索赔流程、配件库存和结算对账等核心能力。
## 核心功能
- 经销商档案、服务站和维修技师管理
- 车辆档案、保养提醒和维修历史追踪
- 维修工单、派工、完工质检和费用结算
- 配件入库、出库、调拨和库存预警
- 索赔申请、审核、结算和统计报表
## 技术栈
| 层级 | 技术 |
| --- | --- |
| 后端 | Spring Boot、MyBatis、Spring Security |
| 前端 | Vue2、Element UI |
| 数据库 | MySQL |
## 目录结构
\`\`\`text
dms-platform
├─ backend
├─ admin-web
├─ sql
└─ docs
\`\`\`
## 适合人群
适合需要快速交付售后服务、维修工单、配件库存类系统的开发团队。`
},
{
id: 'mall-order-system',
name: '商城订单管理系统',
category: 'mall',
coverText: 'MALL',
summary: '包含商品、订单、支付、库存、优惠券、会员和后台统计模块的商城管理源码。',
price: '¥299',
views: 1980,
updateTime: '2026-05-15',
tags: ['Spring Cloud', 'Redis', 'Vue3'],
modules: ['商品中心', '订单中心', '支付记录', '会员体系', '营销活动'],
scenes: ['电商后台', '订单运营', '会员营销'],
descriptionMd: `# 商城订单管理系统
项目提供从商品上架到订单履约的完整后台管理能力,适合作为中小型商城或订单中台的启动源码。
## 功能模块
- 商品分类、SKU、库存和上下架
- 购物车、订单、支付记录和售后单
- 优惠券、满减活动和会员等级
- 后台统计看板和经营数据导出
## 部署说明
\`\`\`bash
npm install
npm run build
\`\`\`
后端服务默认读取 MySQL 与 Redis 配置,可按环境拆分配置文件。`
},
{
id: 'oa-approval-suite',
name: 'OA 办公审批系统',
category: 'office',
coverText: 'OA',
summary: '集成用户、角色、菜单、审批流、请假、报销、公告和文件管理的办公源码。',
price: '¥268',
views: 1750,
updateTime: '2026-05-12',
tags: ['Sa-Token', 'Element UI', '工作流'],
modules: ['审批流程', '请假报销', '公告通知', '文件管理', '权限菜单'],
scenes: ['企业内网', '行政审批', '协同办公'],
descriptionMd: `# OA 办公审批系统
这是一套偏企业内网办公的基础源码,重点覆盖审批、公告、文件与权限管理。
## 亮点
- 可配置审批节点和审批人
- 请假、报销、外出等常见办公流程
- 公告通知、附件上传和文件归档
- 角色、菜单、按钮级权限控制
> 适合二次开发为企业内部管理平台。`
},
{
id: 'erp-warehouse',
name: 'ERP 仓储管理系统',
category: 'enterprise',
coverText: 'ERP',
summary: '提供采购、销售、入库、出库、盘点、供应商和库存报表模块的仓储源码。',
price: '¥199',
views: 1320,
updateTime: '2026-05-09',
tags: ['ERP', 'MyBatis-Plus', 'Excel'],
modules: ['采购入库', '销售出库', '库存盘点', '供应商管理', '报表导出'],
scenes: ['仓储管理', '进销存', '供应链后台'],
descriptionMd: `# ERP 仓储管理系统
源码聚焦进销存和仓储管理,适合小型供应链、仓库和贸易公司快速搭建业务后台。
## 模块清单
- 采购订单、采购入库和供应商档案
- 销售订单、销售出库和客户档案
- 库存流水、库存盘点和预警规则
- Excel 导入导出与库存报表`
},
{
id: 'lowcode-form-platform',
name: '低代码表单平台',
category: 'lowcode',
coverText: 'FORM',
summary: '支持动态表单、拖拽布局、字段配置、数据字典和在线设计的低代码源码。',
price: '¥499',
views: 980,
updateTime: '2026-05-06',
tags: ['Vue3', 'Element Plus', '拖拽设计'],
modules: ['表单设计器', '字段配置', '动态校验', '数据字典', '表单预览'],
scenes: ['低代码平台', '动态表单', '配置化后台'],
descriptionMd: `# 低代码表单平台
项目用于构建配置化表单和数据采集页面,包含设计器、字段配置、预览和提交数据管理。
## 能力范围
- 拖拽式表单组件编排
- 字段属性、校验规则和选项配置
- 字典数据联动
- 表单预览与数据提交记录`
},
{
id: 'microservice-auth-starter',
name: '微服务权限脚手架',
category: 'microservice',
coverText: 'MS',
summary: '集成网关、认证中心、系统服务、日志服务、文件服务和监控基础能力的脚手架源码。',
price: '¥599',
views: 2560,
updateTime: '2026-05-03',
tags: ['Spring Cloud', 'Nacos', 'Gateway'],
modules: ['网关服务', '认证中心', '系统服务', '日志中心', '文件服务'],
scenes: ['微服务基础架构', '权限平台', '多服务治理'],
descriptionMd: `# 微服务权限脚手架
这套源码适合从单体后台升级到微服务架构的团队,预置认证、网关、系统权限和基础监控能力。
## 服务组成
- Gateway 网关统一鉴权和路由
- Auth 认证中心
- System 系统权限服务
- Log 操作日志服务
- File 文件服务
## 推荐用途
可作为企业级微服务后台的基础工程,按业务域继续拆分服务。`
}
]
export function listSourceCategories() {
const usedCategories = new Set(sourceProjects.map((project) => project.category))
return [
{ value: 'all', label: '全部源码' },
...sourceCategories.filter((category) => usedCategories.has(category.value))
]
}
export function findSourceProject(id) {
return sourceProjects.find((project) => project.id === id) || null
}
export function filterSourceProjects({ category = 'all', keyword = '' } = {}) {
const normalizedKeyword = keyword.trim().toLowerCase()
return sourceProjects.filter((project) => {
const categoryMatched = category === 'all' || project.category === category
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)
})
}

View File

@@ -0,0 +1,46 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { filterSourceProjects, findSourceProject, listSourceCategories, sourceProjects } from './sourceProjects.js'
test('sourceProjects exposes visible projects with required display fields', () => {
assert.ok(sourceProjects.length >= 6)
sourceProjects.forEach((project) => {
assert.ok(project.id)
assert.ok(project.name)
assert.ok(project.summary)
assert.ok(project.coverText)
assert.equal(Object.prototype.hasOwnProperty.call(project, 'sourceSize'), false)
assert.ok(Array.isArray(project.tags))
assert.ok(project.tags.length >= 3)
assert.ok(project.descriptionMd.includes('# '))
})
})
test('listSourceCategories includes all plus project categories without duplicates', () => {
const categories = listSourceCategories()
assert.equal(categories[0].value, 'all')
assert.equal(categories[0].label, '全部源码')
assert.deepEqual(new Set(categories.map((category) => category.value)).size, categories.length)
assert.ok(categories.some((category) => category.value === 'enterprise'))
assert.ok(categories.some((category) => category.value === 'mall'))
})
test('findSourceProject returns project by id and falls back to null', () => {
assert.equal(findSourceProject('dms-platform')?.name, '商用车 DMS 管理系统')
assert.equal(findSourceProject('missing-project'), null)
})
test('filterSourceProjects filters by category and keyword', () => {
const mallProjects = filterSourceProjects({ category: 'mall' })
assert.deepEqual(
mallProjects.map((project) => project.id),
['mall-order-system']
)
const searchProjects = filterSourceProjects({ category: 'all', keyword: '审批' })
assert.deepEqual(
searchProjects.map((project) => project.id),
['oa-approval-suite']
)
})

View File

@@ -5,12 +5,18 @@ import HomeView from '@/views/HomeView.vue'
import LoginView from '@/views/LoginView.vue'
import RegisterView from '@/views/RegisterView.vue'
import GenerateView from '@/views/GenerateView.vue'
import ProjectListView from '@/views/ProjectListView.vue'
import PreviewView from '@/views/PreviewView.vue'
import SourceDetailView from '@/views/SourceDetailView.vue'
import SourceStoreView from '@/views/SourceStoreView.vue'
const routes = [
{ path: '/', name: 'home', component: HomeView },
{ path: '/login', name: 'login', component: LoginView },
{ path: '/register', name: 'register', component: RegisterView },
{ path: '/source-store', name: 'source-store', component: SourceStoreView },
{ path: '/source-store/:sourceId', name: 'source-detail', component: SourceDetailView },
{ path: '/projects', name: 'projects', component: ProjectListView, meta: { requiresAuth: true } },
{ path: '/generate', name: 'generate', component: GenerateView, meta: { requiresAuth: true } },
{
path: '/project/:projectId/preview',

View File

@@ -0,0 +1,17 @@
export function saveBlob(blob, filename) {
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
export function sourceZipName(project) {
const fallback = project?.projectId ? `project-${project.projectId}` : 'easycode-project'
const rawName = project?.projectFileName || project?.projectName || fallback
const safeName = String(rawName).trim().replace(/[\\/:*?"<>|]/g, '_') || fallback
return `${safeName}-source.zip`
}

View File

@@ -0,0 +1,92 @@
const HEADER_HEIGHT = 60
const FIELD_HEIGHT = 28
function escapeXml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
function truncate(value, maxLength) {
const text = String(value ?? '')
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text
}
function badgeOf(field) {
if (field.primary) {
return { text: 'PK', fill: '#dbeafe', color: '#1d4ed8' }
}
if (field.required) {
return { text: '*', fill: '#fef3c7', color: '#b45309' }
}
return { text: '-', fill: '#ffffff', color: '#cbd5e1' }
}
function renderEdge(edge) {
return `
<path class="relation-line${edge.inferred ? '' : ' manual'}" d="${escapeXml(edge.path)}" marker-end="url(#er-arrow)" />
<text class="relation-label" x="${edge.labelX}" y="${edge.labelY}">${escapeXml(edge.fieldName)}</text>`
}
function renderField(field, index) {
const y = HEADER_HEIGHT + index * FIELD_HEIGHT + 20
const badge = badgeOf(field)
return `
<rect x="12" y="${y - 14}" width="26" height="18" rx="4" fill="${badge.fill}" />
<text x="25" y="${y}" class="field-badge" fill="${badge.color}">${escapeXml(badge.text)}</text>
<text x="48" y="${y}" class="field-name">${escapeXml(truncate(field.name, 18))}</text>
<text x="152" y="${y}" class="field-comment">${escapeXml(truncate(field.comment, 12))}</text>`
}
function renderNode(node) {
const fields = node.fields.length
? node.fields.map(renderField).join('')
: '<text x="130" y="92" class="field-empty">No fields</text>'
return `
<g transform="translate(${node.x}, ${node.y})">
<rect class="entity-box" width="${node.width}" height="${node.height}" rx="8" />
<rect class="entity-head-bg" width="${node.width}" height="${HEADER_HEIGHT}" rx="8" />
<rect x="0" y="52" width="${node.width}" height="8" fill="#eef5ff" />
<line x1="0" y1="${HEADER_HEIGHT}" x2="${node.width}" y2="${HEADER_HEIGHT}" class="entity-divider" />
<text x="14" y="27" class="entity-title">${escapeXml(truncate(node.title, 24))}</text>
<text x="14" y="47" class="entity-comment">${escapeXml(truncate(node.comment, 26))}</text>
${fields}
</g>`
}
export function buildErExportSvg({ width, height, nodes, edges }) {
const canvasWidth = Math.max(1, Number(width) || 1)
const canvasHeight = Math.max(1, Number(height) || 1)
const exportNodes = Array.isArray(nodes) ? nodes : []
const exportEdges = Array.isArray(edges) ? edges : []
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${canvasWidth}" height="${canvasHeight}" viewBox="0 0 ${canvasWidth} ${canvasHeight}">
<defs>
<marker id="er-arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L8,3 z" fill="#8aa0bc" />
</marker>
<style>
.relation-line{fill:none;stroke:#8aa0bc;stroke-width:1.6;stroke-dasharray:6 5}
.relation-line.manual{stroke:#2563eb;stroke-dasharray:none}
.relation-label{fill:#64748b;font-family:Arial,sans-serif;font-size:12px;paint-order:stroke;stroke:#f8fafc;stroke-width:4px;text-anchor:middle}
.entity-box{fill:#fff;stroke:#d6e0ee;stroke-width:1}
.entity-head-bg{fill:#eef5ff}
.entity-divider{stroke:#e7edf5;stroke-width:1}
.entity-title{fill:#0f172a;font-family:Arial,sans-serif;font-size:15px;font-weight:700}
.entity-comment{fill:#64748b;font-family:Arial,sans-serif;font-size:12px}
.field-badge{font-family:Arial,sans-serif;font-size:11px;font-weight:700;text-anchor:middle}
.field-name{fill:#334155;font-family:Arial,sans-serif;font-size:13px}
.field-comment{fill:#94a3b8;font-family:Arial,sans-serif;font-size:12px}
.field-empty{fill:#94a3b8;font-family:Arial,sans-serif;font-size:13px;text-anchor:middle}
</style>
</defs>
<rect width="100%" height="100%" fill="#f8fafc" />
<g>${exportEdges.map(renderEdge).join('')}</g>
<g>${exportNodes.map(renderNode).join('')}</g>
</svg>`
}

View File

@@ -0,0 +1,41 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { buildErExportSvg } from './erExport.js'
test('buildErExportSvg renders a pure SVG that can be drawn to canvas safely', () => {
const svg = buildErExportSvg({
width: 900,
height: 480,
nodes: [
{
id: 'student_info',
title: 'student_info',
comment: 'Student table',
x: 20,
y: 30,
width: 260,
height: 132,
fields: [
{ name: 'id', comment: 'ID', primary: true, required: true },
{ name: 'department_id', comment: 'Department', primary: false, required: false }
]
}
],
edges: [
{
id: 'student_info.department_id->department.id',
path: 'M 280 120 C 340 120, 420 120, 480 120',
labelX: 380,
labelY: 112,
fieldName: 'department_id',
inferred: true
}
]
})
assert.match(svg, /^<\?xml version="1.0" encoding="UTF-8"\?>/)
assert.equal(svg.includes('<foreignObject'), false)
assert.equal(svg.includes('xmlns="http://www.w3.org/2000/svg"'), true)
assert.equal(svg.includes('student_info'), true)
assert.equal(svg.includes('department_id'), true)
})

View File

@@ -0,0 +1,154 @@
const NODE_WIDTH = 260
const HEADER_HEIGHT = 60
const FIELD_HEIGHT = 28
const NODE_GAP_X = 80
const NODE_GAP_Y = 72
const MIN_NODE_HEIGHT = 132
function flagOn(value) {
return value === true || value === 1 || value === '1' || value === 'true'
}
function cleanName(value, fallback) {
const text = String(value || '').trim()
return text || fallback
}
function normalizeKey(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]/g, '')
}
function tableIdOf(table, index) {
return cleanName(table?.tableName || table?.id || table?.tableId, `table_${index + 1}`)
}
function buildField(column, index) {
return {
id: cleanName(column?.columnName || column?.id || column?.columnId, `field_${index + 1}`),
name: cleanName(column?.columnName, `field_${index + 1}`),
comment: String(column?.columnComment || '').trim(),
type: String(column?.columnType || '').trim(),
primary: flagOn(column?.isPk),
required: flagOn(column?.isRequired)
}
}
function buildNode(table, index, columns) {
const fields = columns.map(buildField)
const x = (index % 3) * (NODE_WIDTH + NODE_GAP_X)
const y = Math.floor(index / 3) * (MIN_NODE_HEIGHT + NODE_GAP_Y)
const height = Math.max(MIN_NODE_HEIGHT, HEADER_HEIGHT + fields.length * FIELD_HEIGHT + 18)
return {
id: tableIdOf(table, index),
title: cleanName(table?.tableName, `table_${index + 1}`),
comment: String(table?.tableComment || '').trim(),
x,
y,
width: NODE_WIDTH,
height,
fields
}
}
function savedPositionFor(draft, nodeId) {
const position = draft?.positions?.[nodeId]
const x = Number(position?.x)
const y = Number(position?.y)
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return null
}
return { x, y }
}
function targetCandidates(base) {
return [base, `${base}_info`, `${base}_table`, `${base}s`].map(normalizeKey)
}
function findTargetNode(nodes, base, sourceId) {
const candidates = targetCandidates(base)
const exact = nodes.find((node) => node.id !== sourceId && candidates.includes(normalizeKey(node.title)))
if (exact) return exact
return nodes.find((node) => {
if (node.id === sourceId) return false
const nodeKey = normalizeKey(node.title)
return nodeKey.startsWith(normalizeKey(`${base}_`)) || nodeKey.startsWith(normalizeKey(base))
})
}
function primaryFieldOf(node) {
return node.fields.find((field) => field.primary) || node.fields.find((field) => field.name === 'id') || node.fields[0]
}
function buildInferredEdges(nodes, deletedEdgeIds) {
const edges = []
const deleted = new Set(Array.isArray(deletedEdgeIds) ? deletedEdgeIds : [])
nodes.forEach((sourceNode) => {
sourceNode.fields.forEach((field) => {
const fieldName = String(field.name || '').toLowerCase()
if (field.primary || !fieldName.endsWith('_id')) return
const base = fieldName.slice(0, -3)
if (!base) return
const targetNode = findTargetNode(nodes, base, sourceNode.id)
if (!targetNode) return
const targetField = primaryFieldOf(targetNode)
const id = `${sourceNode.id}.${field.name}->${targetNode.id}.${targetField?.name || 'id'}`
if (deleted.has(id)) return
edges.push({
id,
source: sourceNode.id,
target: targetNode.id,
fieldName: field.name,
targetFieldName: targetField?.name || 'id',
inferred: true
})
})
})
return edges
}
function buildManualEdges(nodes, relations) {
const nodeIds = new Set(nodes.map((node) => node.id))
return (Array.isArray(relations) ? relations : [])
.filter((relation) => nodeIds.has(relation?.source) && nodeIds.has(relation?.target))
.map((relation, index) => ({
id: cleanName(relation.id, `manual_${index + 1}`),
source: relation.source,
target: relation.target,
fieldName: cleanName(relation.fieldName, 'id'),
targetFieldName: cleanName(relation.targetFieldName, 'id'),
inferred: false
}))
}
function applyPositions(nodes, draft) {
return nodes.map((node) => {
const position = savedPositionFor(draft, node.id)
return position ? { ...node, ...position } : node
})
}
export function buildErGraph(tables, draft = {}) {
const sourceTables = Array.isArray(tables) ? tables : []
const nodes = applyPositions(
sourceTables.map((table, index) => buildNode(table, index, Array.isArray(table?.columns) ? table.columns : [])),
draft
)
return {
nodes,
edges: [
...buildInferredEdges(nodes, draft?.deletedEdgeIds),
...buildManualEdges(nodes, draft?.relations)
]
}
}

View File

@@ -0,0 +1,95 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { buildErGraph } from './erGraph.js'
test('buildErGraph creates table nodes with primary and required fields', () => {
const graph = buildErGraph([
{
tableName: 'department',
tableComment: '院系表',
columns: [
{ columnName: 'id', columnComment: '主键ID', isPk: '1', isRequired: '1' },
{ columnName: 'dept_name', columnComment: '院系名称', isRequired: '1' }
]
}
])
assert.equal(graph.nodes.length, 1)
assert.equal(graph.nodes[0].title, 'department')
assert.equal(graph.nodes[0].comment, '院系表')
assert.deepEqual(
graph.nodes[0].fields.map((field) => ({ name: field.name, primary: field.primary, required: field.required })),
[
{ name: 'id', primary: true, required: true },
{ name: 'dept_name', primary: false, required: true }
]
)
})
test('buildErGraph infers foreign-key style edges from xxx_id fields', () => {
const graph = buildErGraph([
{
tableName: 'department',
columns: [{ columnName: 'id', isPk: '1' }]
},
{
tableName: 'student_info',
columns: [
{ columnName: 'id', isPk: '1' },
{ columnName: 'department_id', columnComment: '所属院系' }
]
}
])
assert.equal(graph.edges.length, 1)
assert.deepEqual(
{
source: graph.edges[0].source,
target: graph.edges[0].target,
fieldName: graph.edges[0].fieldName,
targetFieldName: graph.edges[0].targetFieldName
},
{
source: 'student_info',
target: 'department',
fieldName: 'department_id',
targetFieldName: 'id'
}
)
})
test('buildErGraph applies saved node positions', () => {
const graph = buildErGraph(
[{ tableName: 'department', columns: [{ columnName: 'id', isPk: '1' }] }],
{ positions: { department: { x: 120, y: 240 } } }
)
assert.equal(graph.nodes[0].x, 120)
assert.equal(graph.nodes[0].y, 240)
})
test('buildErGraph merges manual relations and hides deleted inferred relations', () => {
const graph = buildErGraph(
[
{ tableName: 'department', columns: [{ columnName: 'id', isPk: '1' }] },
{ tableName: 'student_info', columns: [{ columnName: 'id', isPk: '1' }, { columnName: 'department_id' }] },
{ tableName: 'class_info', columns: [{ columnName: 'id', isPk: '1' }] }
],
{
deletedEdgeIds: ['student_info.department_id->department.id'],
relations: [
{
id: 'manual_student_class',
source: 'student_info',
target: 'class_info',
fieldName: 'id',
targetFieldName: 'id'
}
]
}
)
assert.equal(graph.edges.length, 1)
assert.equal(graph.edges[0].id, 'manual_student_class')
assert.equal(graph.edges[0].inferred, false)
})

View File

@@ -0,0 +1,148 @@
const escapeMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => escapeMap[char])
}
function renderInline(value) {
let html = escapeHtml(value)
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
return html
}
function renderTable(rows) {
if (rows.length < 2) return ''
const headers = splitTableRow(rows[0])
const bodyRows = rows.slice(2).map(splitTableRow)
const headerHtml = headers.map((header) => `<th>${renderInline(header)}</th>`).join('')
const bodyHtml = bodyRows
.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(cell)}</td>`).join('')}</tr>`)
.join('')
return `<table><thead><tr>${headerHtml}</tr></thead><tbody>${bodyHtml}</tbody></table>`
}
function splitTableRow(row) {
return row
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
function isTableDivider(line) {
return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line)
}
function flushParagraph(parts, output) {
if (!parts.length) return
output.push(`<p>${renderInline(parts.join(' '))}</p>`)
parts.length = 0
}
function flushList(items, output) {
if (!items.length) return
output.push(`<ul>${items.map((item) => `<li>${renderInline(item)}</li>`).join('')}</ul>`)
items.length = 0
}
function flushTable(rows, output) {
if (!rows.length) return
const html = renderTable(rows)
if (html) {
output.push(html)
}
rows.length = 0
}
export function renderMarkdown(markdown) {
const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n')
const output = []
const paragraph = []
const listItems = []
const tableRows = []
let codeLanguage = ''
let codeLines = []
function flushBlocks() {
flushParagraph(paragraph, output)
flushList(listItems, output)
flushTable(tableRows, output)
}
lines.forEach((line) => {
const fenceMatch = line.match(/^```(\w+)?\s*$/)
if (fenceMatch) {
if (codeLanguage || codeLines.length) {
const languageClass = codeLanguage ? ` class="language-${escapeHtml(codeLanguage)}"` : ''
output.push(`<pre><code${languageClass}>${escapeHtml(`${codeLines.join('\n')}\n`)}</code></pre>`)
codeLanguage = ''
codeLines = []
} else {
flushBlocks()
codeLanguage = fenceMatch[1] || 'text'
}
return
}
if (codeLanguage || codeLines.length) {
codeLines.push(line)
return
}
if (!line.trim()) {
flushBlocks()
return
}
if (tableRows.length || (line.includes('|') && lines[lines.indexOf(line) + 1] && isTableDivider(lines[lines.indexOf(line) + 1]))) {
flushParagraph(paragraph, output)
flushList(listItems, output)
tableRows.push(line)
return
}
const headingMatch = line.match(/^(#{1,4})\s+(.+)$/)
if (headingMatch) {
flushBlocks()
const level = headingMatch[1].length
output.push(`<h${level}>${renderInline(headingMatch[2])}</h${level}>`)
return
}
const listMatch = line.match(/^\s*[-*]\s+(.+)$/)
if (listMatch) {
flushParagraph(paragraph, output)
listItems.push(listMatch[1])
return
}
const quoteMatch = line.match(/^>\s+(.+)$/)
if (quoteMatch) {
flushBlocks()
output.push(`<blockquote>${renderInline(quoteMatch[1])}</blockquote>`)
return
}
flushList(listItems, output)
flushTable(tableRows, output)
paragraph.push(line.trim())
})
if (codeLanguage || codeLines.length) {
const languageClass = codeLanguage ? ` class="language-${escapeHtml(codeLanguage)}"` : ''
output.push(`<pre><code${languageClass}>${escapeHtml(`${codeLines.join('\n')}\n`)}</code></pre>`)
}
flushBlocks()
return output.join('\n')
}

View File

@@ -0,0 +1,22 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { renderMarkdown } from './markdown.js'
test('renderMarkdown renders common markdown and keeps raw html escaped', () => {
const html = renderMarkdown(`# 项目介绍
- Spring Boot
- Vue3
\`\`\`bash
npm run dev
\`\`\`
<script>alert('xss')</script>`)
assert.match(html, /<h1>项目介绍<\/h1>/)
assert.match(html, /<li>Spring Boot<\/li>/)
assert.match(html, /<pre><code class="language-bash">npm run dev\n<\/code><\/pre>/)
assert.equal(html.includes('<script>'), false)
assert.ok(html.includes('&lt;script&gt;alert'))
})

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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 = ''

View 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>

View 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>

View 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>

View 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)
})