增加便捷工具

This commit is contained in:
2026-06-17 17:18:49 +08:00
parent 9e482a12be
commit 673b84a0b3
50 changed files with 3135 additions and 9118 deletions
+23
View File
@@ -427,9 +427,32 @@ h3 {
:deep(.el-menu) { :deep(.el-menu) {
border-right: none; border-right: none;
height: calc(100% - 80px); height: calc(100% - 80px);
overflow-y: auto;
overflow-x: hidden;
padding: 16px 8px; padding: 16px 8px;
background: transparent; background: transparent;
// 自定义滚动条样式
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
&:hover {
background: rgba(255, 255, 255, 0.5);
}
}
.el-menu-item, .el-menu-item,
.el-sub-menu__title { .el-sub-menu__title {
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
+24 -2
View File
@@ -33,6 +33,7 @@ var validModules = map[string]bool{
"cursor": true, "cursor": true,
"windsurf": true, "windsurf": true,
"krio": true, "krio": true,
"codex": true,
} }
func (c *ApiGetCardController) cardErr(_ int, _ int, msg string) { func (c *ApiGetCardController) cardErr(_ int, _ int, msg string) {
@@ -52,7 +53,7 @@ func (c *ApiGetCardController) cardOK(text string) {
// //
// 参数: // 参数:
// - type (必填) 来源平台:xianyu / taobao / pinduoduo / jingdong / local / xubei // - type (必填) 来源平台:xianyu / taobao / pinduoduo / jingdong / local / xubei
// - module (必填) 号池模块:cursor / windsurf / krio // - module (必填) 号池模块:cursor / windsurf / krio / codex
// - data_type (可选) 账号类型:account / tk / account_tk,不传则取任意未提取的 // - data_type (可选) 账号类型:account / tk / account_tk,不传则取任意未提取的
// - id/start_id/current_id (可选) 起始 ID:从该 ID 开始向后提取,避免传 896 却取到 892 // - id/start_id/current_id (可选) 起始 ID:从该 ID 开始向后提取,避免传 896 却取到 892
func (c *ApiGetCardController) GetCard() { func (c *ApiGetCardController) GetCard() {
@@ -79,7 +80,7 @@ func (c *ApiGetCardController) GetCard() {
return return
} }
if !validModules[module] { if !validModules[module] {
c.cardErr(400, 400, fmt.Sprintf("不支持的模块: %s,支持: cursor/windsurf/krio", module)) c.cardErr(400, 400, fmt.Sprintf("不支持的模块: %s,支持: cursor/windsurf/krio/codex", module))
return return
} }
if dataType != "" && !isValidPoolType(dataType) { if dataType != "" && !isValidPoolType(dataType) {
@@ -96,6 +97,8 @@ func (c *ApiGetCardController) GetCard() {
c.extractWindsurf(platform, dataType, startID, now) c.extractWindsurf(platform, dataType, startID, now)
case "krio": case "krio":
c.extractKrio(platform, dataType, startID, now) c.extractKrio(platform, dataType, startID, now)
case "codex":
c.extractCodex(platform, dataType, startID, now)
} }
} }
@@ -175,6 +178,25 @@ func (c *ApiGetCardController) extractKrio(platform, dataType string, startID ui
}) })
} }
func (c *ApiGetCardController) extractCodex(platform, dataType string, startID uint64, now time.Time) {
c.extractWithProbe("codex", platform, dataType, now, func() (uint64, *string, *string, string, string, *int8, error) {
var row models.PlatformAccountPoolCodex
qs := models.Orm.QueryTable(new(models.PlatformAccountPoolCodex)).
Filter("is_extracted", 0).
Filter("delete_time__isnull", true)
if startID > 0 {
qs = qs.Filter("id__gte", startID)
}
if dataType != "" {
qs = qs.Filter("data_type", dataType)
}
if err := qs.OrderBy("id").One(&row); err != nil {
return 0, nil, nil, "", "", nil, err
}
return row.ID, &row.Account, &row.Password, row.Token, row.DataType, nil, nil
})
}
type poolRowFetcher func() (id uint64, account, password *string, token, rowDataType string, isUsed *int8, err error) type poolRowFetcher func() (id uint64, account, password *string, token, rowDataType string, isUsed *int8, err error)
// extractWithProbe 按 id 顺序提取并探测 Token 可用性;不可用则标记已提取并继续下一条。 // extractWithProbe 按 id 顺序提取并探测 Token 可用性;不可用则标记已提取并继续下一条。
+134
View File
@@ -20,6 +20,7 @@ import (
type PlatformAccountPoolCursorController struct{ beego.Controller } type PlatformAccountPoolCursorController struct{ beego.Controller }
type PlatformAccountPoolWindsurfController struct{ beego.Controller } type PlatformAccountPoolWindsurfController struct{ beego.Controller }
type PlatformAccountPoolKrioController struct{ beego.Controller } type PlatformAccountPoolKrioController struct{ beego.Controller }
type PlatformAccountPoolCodexController struct{ beego.Controller }
type accountPoolCreateRow struct { type accountPoolCreateRow struct {
DataType string `json:"type"` DataType string `json:"type"`
@@ -265,6 +266,28 @@ func listPoolRows(c *beego.Controller, module string) {
rows = []models.PlatformAccountPoolKiro{} rows = []models.PlatformAccountPoolKiro{}
} }
list = rows list = rows
case "codex":
table := (&models.PlatformAccountPoolCodex{}).TableName()
total, err = accountPoolCountMySQL(table, where, whereArgs)
if err != nil {
poolJSONErr(c, 500, 500, "获取列表失败: "+err.Error())
return
}
sqlStr := fmt.Sprintf(
"SELECT * FROM `%s` WHERE %s ORDER BY FIELD(is_extracted, 0, 2, 3, 1) ASC, id DESC LIMIT ? OFFSET ?",
table, where,
)
args := append(append([]interface{}{}, whereArgs...), pageSize, offset)
var rows []models.PlatformAccountPoolCodex
_, err = models.Orm.Raw(sqlStr, args...).QueryRows(&rows)
if err != nil && err != orm.ErrNoRows {
poolJSONErr(c, 500, 500, "获取列表失败: "+err.Error())
return
}
if rows == nil {
rows = []models.PlatformAccountPoolCodex{}
}
list = rows
default: default:
poolJSONErr(c, 400, 400, "无效模块") poolJSONErr(c, 400, 400, "无效模块")
return return
@@ -332,6 +355,16 @@ func addPoolRow(c *beego.Controller, module string) {
IsExtracted: 0, IsExtracted: 0,
} }
_, err = models.Orm.Insert(r) _, err = models.Orm.Insert(r)
case "codex":
r := &models.PlatformAccountPoolCodex{
DataType: strings.TrimSpace(row.DataType),
Account: strings.TrimSpace(row.Account),
Password: strings.TrimSpace(row.Password),
Token: strings.TrimSpace(row.Token),
Remark: strings.TrimSpace(row.Remark),
IsExtracted: 0,
}
_, err = models.Orm.Insert(r)
default: default:
poolJSONErr(c, 400, 400, "无效模块") poolJSONErr(c, 400, 400, "无效模块")
return return
@@ -397,6 +430,15 @@ func batchAddPoolRows(c *beego.Controller, module string) {
Remark: strings.TrimSpace(row.Remark), Remark: strings.TrimSpace(row.Remark),
IsExtracted: 0, IsExtracted: 0,
}) })
case "codex":
_, err = models.Orm.Insert(&models.PlatformAccountPoolCodex{
DataType: strings.TrimSpace(row.DataType),
Account: strings.TrimSpace(row.Account),
Password: strings.TrimSpace(row.Password),
Token: strings.TrimSpace(row.Token),
Remark: strings.TrimSpace(row.Remark),
IsExtracted: 0,
})
default: default:
poolJSONErr(c, 400, 400, "无效模块") poolJSONErr(c, 400, 400, "无效模块")
return return
@@ -443,6 +485,13 @@ func getPoolDetail(c *beego.Controller, module string) {
return return
} }
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": row} c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": row}
case "codex":
var row models.PlatformAccountPoolCodex
if err := models.Orm.QueryTable(new(models.PlatformAccountPoolCodex)).Filter("id", id).One(&row); err != nil {
poolJSONErr(c, 404, 404, "记录不存在")
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": row}
default: default:
poolJSONErr(c, 400, 400, "无效模块") poolJSONErr(c, 400, 400, "无效模块")
return return
@@ -573,6 +622,34 @@ func extractPoolRow(c *beego.Controller, module string) {
row.ExtractedPlatform = &pf row.ExtractedPlatform = &pf
row.Remark = remark row.Remark = remark
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "提取成功", "data": row} c.Data["json"] = map[string]interface{}{"code": 200, "msg": "提取成功", "data": row}
case "codex":
var row models.PlatformAccountPoolCodex
qs := models.Orm.QueryTable(new(models.PlatformAccountPoolCodex)).Filter("is_extracted", 0)
if payload.ID > 0 {
qs = qs.Filter("id", payload.ID)
} else if payload.Type != "" {
qs = qs.Filter("data_type", payload.Type)
}
if err := qs.OrderBy("id").One(&row); err != nil {
poolJSONErr(c, 404, 404, "没有可提取数据")
return
}
_, err = models.Orm.QueryTable(new(models.PlatformAccountPoolCodex)).Filter("id", row.ID).Update(map[string]interface{}{
"is_extracted": extractStatus,
"extracted_time": now,
"extracted_platform": platform,
"remark": remark,
})
if err != nil {
poolJSONErr(c, 500, 500, "提取失败: "+err.Error())
return
}
row.IsExtracted = extractStatus
row.ExtractedTime = &now
pf := platform
row.ExtractedPlatform = &pf
row.Remark = remark
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "提取成功", "data": row}
default: default:
poolJSONErr(c, 400, 400, "无效模块") poolJSONErr(c, 400, 400, "无效模块")
return return
@@ -678,6 +755,22 @@ func replenishWithProbe(c *beego.Controller, module, dataType, platform, remark
id: row.ID, dataType: row.DataType, token: row.Token, row: row, id: row.ID, dataType: row.DataType, token: row.Token, row: row,
}, nil }, nil
} }
case "codex":
fetch = func() (*poolReplenishCandidate, error) {
var row models.PlatformAccountPoolCodex
err := models.Orm.QueryTable(new(models.PlatformAccountPoolCodex)).
Filter("is_extracted", 0).
Filter("data_type", dataType).
Filter("delete_time__isnull", true).
OrderBy("id").
One(&row)
if err != nil {
return nil, err
}
return &poolReplenishCandidate{
id: row.ID, dataType: row.DataType, token: row.Token, row: row,
}, nil
}
default: default:
poolJSONErr(c, 400, 400, "无效模块") poolJSONErr(c, 400, 400, "无效模块")
return return
@@ -754,6 +847,12 @@ func replenishApplyResponse(row interface{}, platform, remark string, now time.T
r.ExtractedPlatform = &pf r.ExtractedPlatform = &pf
r.Remark = remark r.Remark = remark
return r return r
case models.PlatformAccountPoolCodex:
r.IsExtracted = 2
r.ExtractedTime = &now
r.ExtractedPlatform = &pf
r.Remark = remark
return r
default: default:
return row return row
} }
@@ -793,6 +892,10 @@ func updatePoolRemark(c *beego.Controller, module string) {
updated, err = models.Orm.QueryTable(new(models.PlatformAccountPoolKiro)).Filter("id", payload.ID).Update(map[string]interface{}{ updated, err = models.Orm.QueryTable(new(models.PlatformAccountPoolKiro)).Filter("id", payload.ID).Update(map[string]interface{}{
"remark": remark, "remark": remark,
}) })
case "codex":
updated, err = models.Orm.QueryTable(new(models.PlatformAccountPoolCodex)).Filter("id", payload.ID).Update(map[string]interface{}{
"remark": remark,
})
default: default:
poolJSONErr(c, 400, 400, "无效模块") poolJSONErr(c, 400, 400, "无效模块")
return return
@@ -826,6 +929,8 @@ func updatePoolExtractFields(module string, id uint64, fields map[string]interfa
return models.Orm.QueryTable(new(models.PlatformAccountPoolWindsurf)).Filter("id", id).Update(fields) return models.Orm.QueryTable(new(models.PlatformAccountPoolWindsurf)).Filter("id", id).Update(fields)
case "krio": case "krio":
return models.Orm.QueryTable(new(models.PlatformAccountPoolKiro)).Filter("id", id).Update(fields) return models.Orm.QueryTable(new(models.PlatformAccountPoolKiro)).Filter("id", id).Update(fields)
case "codex":
return models.Orm.QueryTable(new(models.PlatformAccountPoolCodex)).Filter("id", id).Update(fields)
default: default:
return 0, fmt.Errorf("无效模块") return 0, fmt.Errorf("无效模块")
} }
@@ -1059,6 +1164,17 @@ func probePoolToken(c *beego.Controller, module string) {
return return
} }
token = strings.TrimSpace(row.Token) token = strings.TrimSpace(row.Token)
case "codex":
if payload.ID == 0 {
poolJSONErr(c, 400, 400, "缺少有效 id")
return
}
var row models.PlatformAccountPoolCodex
if err := models.Orm.QueryTable(new(models.PlatformAccountPoolCodex)).Filter("id", payload.ID).One(&row); err != nil {
poolJSONErr(c, 404, 404, "记录不存在")
return
}
token = strings.TrimSpace(row.Token)
default: default:
poolJSONErr(c, 400, 400, "无效模块") poolJSONErr(c, 400, 400, "无效模块")
return return
@@ -1182,3 +1298,21 @@ func (c *PlatformAccountPoolKrioController) UpdatePlatform() {
} }
func (c *PlatformAccountPoolKrioController) Unextract() { unextractPoolRow(&c.Controller, "krio") } func (c *PlatformAccountPoolKrioController) Unextract() { unextractPoolRow(&c.Controller, "krio") }
func (c *PlatformAccountPoolKrioController) ProbeToken() { probePoolToken(&c.Controller, "krio") } func (c *PlatformAccountPoolKrioController) ProbeToken() { probePoolToken(&c.Controller, "krio") }
func (c *PlatformAccountPoolCodexController) List() { listPoolRows(&c.Controller, "codex") }
func (c *PlatformAccountPoolCodexController) Add() { addPoolRow(&c.Controller, "codex") }
func (c *PlatformAccountPoolCodexController) BatchAdd() { batchAddPoolRows(&c.Controller, "codex") }
func (c *PlatformAccountPoolCodexController) Detail() { getPoolDetail(&c.Controller, "codex") }
func (c *PlatformAccountPoolCodexController) Extract() { extractPoolRow(&c.Controller, "codex") }
func (c *PlatformAccountPoolCodexController) Replenish() { replenishPoolRow(&c.Controller, "codex") }
func (c *PlatformAccountPoolCodexController) UpdateRemark() {
updatePoolRemark(&c.Controller, "codex")
}
func (c *PlatformAccountPoolCodexController) SetUnavailable() {
setPoolUnavailable(&c.Controller, "codex")
}
func (c *PlatformAccountPoolCodexController) UpdatePlatform() {
updatePoolPlatform(&c.Controller, "codex")
}
func (c *PlatformAccountPoolCodexController) Unextract() { unextractPoolRow(&c.Controller, "codex") }
func (c *PlatformAccountPoolCodexController) ProbeToken() { probePoolToken(&c.Controller, "codex") }
+3
View File
@@ -217,13 +217,16 @@ func (c *PlatformHomeController) AccountPoolInventoryTotals() {
{Key: "cursor", Label: "Cursor"}, {Key: "cursor", Label: "Cursor"},
{Key: "krio", Label: "Kiro"}, {Key: "krio", Label: "Kiro"},
{Key: "windsurf", Label: "Windsurf"}, {Key: "windsurf", Label: "Windsurf"},
{Key: "codex", Label: "Codex"},
} }
modelsList := []interface{}{ modelsList := []interface{}{
new(models.PlatformAccountPoolCursor), new(models.PlatformAccountPoolCursor),
new(models.PlatformAccountPoolKiro), new(models.PlatformAccountPoolKiro),
new(models.PlatformAccountPoolWindsurf), new(models.PlatformAccountPoolWindsurf),
new(models.PlatformAccountPoolCodex),
} }
var grandTotal, grandSold int64 var grandTotal, grandSold int64
for i := range modules { for i := range modules {
tot, err := countPoolInventory(modelsList[i], false) tot, err := countPoolInventory(modelsList[i], false)
+3
View File
@@ -16,7 +16,10 @@ func poolTableName(module string) string {
return new(models.PlatformAccountPoolWindsurf).TableName() return new(models.PlatformAccountPoolWindsurf).TableName()
case "krio": case "krio":
return new(models.PlatformAccountPoolKiro).TableName() return new(models.PlatformAccountPoolKiro).TableName()
case "codex":
return new(models.PlatformAccountPoolCodex).TableName()
default: default:
return "" return ""
} }
} }
+2
View File
@@ -61,6 +61,8 @@ func Init(_ string) {
new(PlatformAccountPoolKiro), new(PlatformAccountPoolKiro),
new(PlatformAccountPoolWindsurf), new(PlatformAccountPoolWindsurf),
new(PlatformAccountPoolCursor), new(PlatformAccountPoolCursor),
new(PlatformAccountPoolCodex),
new(CmsArticleCategory), new(CmsArticleCategory),
new(CmsArticle), new(CmsArticle),
) )
+21
View File
@@ -22,6 +22,27 @@ func (m *PlatformAccountPoolKiro) TableName() string {
return "yz_platform_account_pool_krio" return "yz_platform_account_pool_krio"
} }
// PlatformAccountPoolCodex 号池表: yz_platform_account_pool_codex
type PlatformAccountPoolCodex struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
DataType string `orm:"column(data_type);size(32)" json:"data_type"` // account | tk | account_tk
Account string `orm:"column(account);size(128);default()" json:"account"`
Password string `orm:"column(password);size(255);default()" json:"password"`
Token string `orm:"column(token);type(text);null" json:"token"`
Remark string `orm:"column(remark);size(255);default()" json:"remark"`
IsExtracted int8 `orm:"column(is_extracted);default(0)" json:"is_extracted"`
ExtractedTime *time.Time `orm:"column(extracted_time);type(datetime);null" json:"extracted_time"`
ExtractedPlatform *string `orm:"column(extracted_platform);size(32);null" json:"extracted_platform"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformAccountPoolCodex) TableName() string {
return "yz_platform_account_pool_codex"
}
// PlatformAccountPoolWindsurf 号池表: yz_platform_account_pool_windsurf // PlatformAccountPoolWindsurf 号池表: yz_platform_account_pool_windsurf
type PlatformAccountPoolWindsurf struct { type PlatformAccountPoolWindsurf struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"` ID uint64 `orm:"column(id);pk;auto" json:"id"`
+13 -1
View File
@@ -183,7 +183,7 @@ func Register() {
beego.Router("/platform/cursor/activationcode/disable/:id", &controllers.PlatformCursorActivationCodeController{}, "post:Disable") beego.Router("/platform/cursor/activationcode/disable/:id", &controllers.PlatformCursorActivationCodeController{}, "post:Disable")
beego.Router("/platform/cursor/activationcode/export", &controllers.PlatformCursorActivationCodeController{}, "get:Export") beego.Router("/platform/cursor/activationcode/export", &controllers.PlatformCursorActivationCodeController{}, "get:Export")
// 账号池管理(cursor/windsurf/krio // 账号池管理(cursor/windsurf/krio/codex
beego.Router("/platform/accountPool/cursor/list", &controllers.PlatformAccountPoolCursorController{}, "get:List") beego.Router("/platform/accountPool/cursor/list", &controllers.PlatformAccountPoolCursorController{}, "get:List")
beego.Router("/platform/accountPool/cursor/add", &controllers.PlatformAccountPoolCursorController{}, "post:Add") beego.Router("/platform/accountPool/cursor/add", &controllers.PlatformAccountPoolCursorController{}, "post:Add")
beego.Router("/platform/accountPool/cursor/batchAdd", &controllers.PlatformAccountPoolCursorController{}, "post:BatchAdd") beego.Router("/platform/accountPool/cursor/batchAdd", &controllers.PlatformAccountPoolCursorController{}, "post:BatchAdd")
@@ -220,4 +220,16 @@ func Register() {
beego.Router("/platform/accountPool/krio/unextract", &controllers.PlatformAccountPoolKrioController{}, "post:Unextract") beego.Router("/platform/accountPool/krio/unextract", &controllers.PlatformAccountPoolKrioController{}, "post:Unextract")
beego.Router("/platform/accountPool/krio/replenish", &controllers.PlatformAccountPoolKrioController{}, "post:Replenish") beego.Router("/platform/accountPool/krio/replenish", &controllers.PlatformAccountPoolKrioController{}, "post:Replenish")
beego.Router("/platform/accountPool/krio/probeToken", &controllers.PlatformAccountPoolKrioController{}, "post:ProbeToken") beego.Router("/platform/accountPool/krio/probeToken", &controllers.PlatformAccountPoolKrioController{}, "post:ProbeToken")
beego.Router("/platform/accountPool/codex/list", &controllers.PlatformAccountPoolCodexController{}, "get:List")
beego.Router("/platform/accountPool/codex/add", &controllers.PlatformAccountPoolCodexController{}, "post:Add")
beego.Router("/platform/accountPool/codex/batchAdd", &controllers.PlatformAccountPoolCodexController{}, "post:BatchAdd")
beego.Router("/platform/accountPool/codex/detail/:id", &controllers.PlatformAccountPoolCodexController{}, "get:Detail")
beego.Router("/platform/accountPool/codex/extract", &controllers.PlatformAccountPoolCodexController{}, "post:Extract")
beego.Router("/platform/accountPool/codex/updateRemark", &controllers.PlatformAccountPoolCodexController{}, "post:UpdateRemark")
beego.Router("/platform/accountPool/codex/setUnavailable", &controllers.PlatformAccountPoolCodexController{}, "post:SetUnavailable")
beego.Router("/platform/accountPool/codex/updatePlatform", &controllers.PlatformAccountPoolCodexController{}, "post:UpdatePlatform")
beego.Router("/platform/accountPool/codex/unextract", &controllers.PlatformAccountPoolCodexController{}, "post:Unextract")
beego.Router("/platform/accountPool/codex/replenish", &controllers.PlatformAccountPoolCodexController{}, "post:Replenish")
beego.Router("/platform/accountPool/codex/probeToken", &controllers.PlatformAccountPoolCodexController{}, "post:ProbeToken")
} }
+23
View File
@@ -530,9 +530,32 @@ h3 {
:deep(.el-menu) { :deep(.el-menu) {
border-right: none; border-right: none;
height: calc(100% - 128px); height: calc(100% - 128px);
overflow-y: auto;
overflow-x: hidden;
padding: 16px 8px; padding: 16px 8px;
background: transparent; background: transparent;
// 自定义滚动条样式
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
&:hover {
background: rgba(255, 255, 255, 0.5);
}
}
.el-menu-item, .el-menu-item,
.el-sub-menu__title { .el-sub-menu__title {
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
@@ -0,0 +1,600 @@
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
row: {
type: Object,
default: null,
},
saveLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "save-remark", "detail-action"]);
const remarkText = ref("");
const remarkDialogVisible = ref(false);
const platformDialogVisible = ref(false);
const unavailableDialogVisible = ref(false);
const unextractDialogVisible = ref(false);
const platformForm = reactive({ platform: "local" });
const TYPE_MAP = {
account: { label: "账号密码", type: "success" },
account_tk: { label: "账号密码+Token", type: "primary" },
tk: { label: "Token", type: "warning" },
};
const PLATFORM_MAP = {
local: { label: "本地", type: "info" },
xianyu: { label: "闲鱼", type: "warning" },
taobao: { label: "淘宝", type: "info" },
pinduoduo: { label: "拼多多", type: "danger" },
jingdong: { label: "京东", type: "primary" },
douyin: { label: "抖音", type: "success" },
ziyoushangcheng: { label: "自有商城", type: "warning" },
};
const statusInfo = computed(() => {
const status = Number(props.row?.extractStatus || 0);
if (status === 2) return { label: "补号", type: "warning" };
if (status === 3) return { label: "续杯", type: "primary" };
if (props.row?.extracted) return { label: "已提取", type: "success" };
return { label: "未提取", type: "info" };
});
const typeInfo = computed(() => {
return (
TYPE_MAP[props.row?.type] || { label: props.row?.type || "-", type: "info" }
);
});
const platformInfo = computed(() => {
const key = props.row?.extractedPlatform;
if (!key) return { label: "-", type: "info" };
return PLATFORM_MAP[key] || { label: key, type: "info" };
});
const isUsedInfo = computed(() => {
const raw = props.row?.isUsed;
if (raw === null || raw === undefined || raw === "") {
return { label: "未探测", type: "info" };
}
const n = Number(raw);
if (n === 1) return { label: "可用", type: "success" };
if (n === 0) return { label: "已用完", type: "danger" };
return { label: String(raw), type: "info" };
});
const hasAccountPassword = computed(
() => !!(props.row?.account || props.row?.password),
);
const hasToken = computed(() => !!props.row?.token);
watch(
() => props.row,
(row) => {
remarkText.value = row?.remark || "";
platformForm.platform = row?.extractedPlatform || "local";
},
{ immediate: true },
);
function closeDialog() {
emit("update:modelValue", false);
}
function openRemarkDialog() {
remarkText.value = props.row?.remark || "";
remarkDialogVisible.value = true;
}
function onSaveRemark() {
if (!props.row?.id) return;
emit("save-remark", { id: props.row.id, remark: remarkText.value || "" });
remarkDialogVisible.value = false;
}
function onSetUnavailable() {
if (!props.row?.id) return;
emit("detail-action", { action: "unavailable", id: props.row.id });
unavailableDialogVisible.value = false;
}
function onUpdatePlatform() {
if (!props.row?.id) return;
emit("detail-action", {
action: "platform",
id: props.row.id,
platform: platformForm.platform,
});
platformDialogVisible.value = false;
}
function onUnextract() {
if (!props.row?.id) return;
emit("detail-action", { action: "unextract", id: props.row.id });
unextractDialogVisible.value = false;
}
async function copyText(text, successText) {
const val = String(text || "").trim();
if (!val) {
ElMessage.warning("暂无可复制内容");
return;
}
try {
await navigator.clipboard.writeText(val);
ElMessage.success(successText || "已复制");
} catch {
ElMessage.error("复制失败,请检查浏览器权限");
}
}
function copyAccountPassword() {
const parts = [];
if (props.row?.account) parts.push(props.row.account);
if (props.row?.password) parts.push(props.row.password);
copyText(parts.join("\n"), "已复制账号+密码");
}
function copyToken() {
copyText(props.row?.token, "已复制 Token");
}
function copyAll() {
const parts = [];
if (props.row?.account) parts.push(`账号:${props.row.account}`);
if (props.row?.password) parts.push(`密码:${props.row.password}`);
if (props.row?.token) parts.push(`Token${props.row.token}`);
copyText(parts.join("\n"), "已复制完整账号信息");
}
</script>
<template>
<el-dialog
class="pool-detail-dialog"
:model-value="modelValue"
width="760px"
destroy-on-close
:show-close="false"
@update:model-value="(v) => emit('update:modelValue', v)"
>
<template #header>
<div class="detail-header">
<div>
<div class="detail-title">账号详情</div>
<div class="detail-subtitle">
通过弹窗执行账号状态、平台、备注等维护操作
</div>
</div>
<div class="header-actions">
<el-button circle plain @click="closeDialog">×</el-button>
</div>
</div>
</template>
<div v-if="row" class="detail-body">
<div class="info-grid">
<div class="info-card">
<div class="info-label">ID</div>
<div class="info-value">{{ row?.id || "-" }}</div>
</div>
<div class="info-card">
<div class="info-label">账号类型</div>
<div class="info-value">
<el-tag :type="typeInfo.type" round>{{ typeInfo.label }}</el-tag>
</div>
</div>
<div class="info-card">
<div class="info-label">提取状态</div>
<div class="info-value">
<el-tag :type="statusInfo.type" effect="dark" round>
{{ statusInfo.label }}
</el-tag>
</div>
</div>
<div class="info-card">
<div class="info-label">提取平台</div>
<div class="info-value">
<el-tag
v-if="row.extractedPlatform"
:type="platformInfo.type"
size="small"
>
{{ platformInfo.label }}
</el-tag>
<span v-else>-</span>
</div>
</div>
<div class="info-card">
<div class="info-label">提取时间</div>
<div class="info-value">{{ row.extractedAt || "-" }}</div>
</div>
<div class="info-card">
<div class="info-label">可用检测</div>
<div class="info-value">
<el-tag :type="isUsedInfo.type" round>
{{ isUsedInfo.label }}
</el-tag>
</div>
</div>
<div class="info-card">
<div class="info-label">账号</div>
<div class="info-value">{{ row.account || "-" }}</div>
</div>
<div class="info-card">
<div class="info-label">密码</div>
<div class="info-value">{{ row.password || "-" }}</div>
</div>
</div>
<div class="section-card">
<div class="section-head">
<div>
<div class="section-title">Token</div>
<div class="section-subtitle">
长 Token 已做自动换行,便于检查与复制
</div>
</div>
<el-button
size="small"
type="primary"
plain
:disabled="!hasToken"
@click="copyToken"
>
复制 Token
</el-button>
</div>
<pre class="token-box">{{ row.token || "暂无 Token" }}</pre>
</div>
<div class="section-card">
<div class="section-head">
<div>
<div class="section-title">快捷功能</div>
<div class="section-subtitle">按使用场景复制账号信息</div>
</div>
</div>
<div class="copy-actions">
<el-button
:disabled="!hasAccountPassword"
@click="copyAccountPassword"
>
复制账号+密码
</el-button>
<el-button
type="primary"
plain
:disabled="!hasToken"
@click="copyToken"
>
复制 Token
</el-button>
<el-button
type="success"
plain
:disabled="!hasAccountPassword && !hasToken"
@click="copyAll"
>
复制全部
</el-button>
</div>
</div>
<div class="section-card">
<div class="section-head">
<div>
<div class="section-title">维护操作</div>
<div class="section-subtitle">
点击按钮后打开确认/编辑弹窗,再执行对应操作
</div>
</div>
</div>
<div class="copy-actions">
<el-button
type="danger"
plain
@click="unavailableDialogVisible = true"
>
改不可用
</el-button>
<el-button
type="warning"
plain
@click="platformDialogVisible = true"
>
改平台
</el-button>
<el-button type="info" plain @click="unextractDialogVisible = true">
反提取
</el-button>
<el-button type="primary" plain @click="openRemarkDialog">
改备注
</el-button>
</div>
</div>
<div class="section-card">
<div class="section-head">
<div>
<div class="section-title">备注</div>
<div class="section-subtitle">备注改为弹窗编辑,当前仅展示</div>
</div>
</div>
<div class="remark-display">{{ row.remark || "暂无备注" }}</div>
</div>
</div>
</el-dialog>
<el-dialog
v-model="unavailableDialogVisible"
title="改不可用"
width="420px"
append-to-body
>
<el-alert type="warning" :closable="false">
确认将当前账号标记为不可用/已用完?
</el-alert>
<template #footer>
<el-button @click="unavailableDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="saveLoading" @click="onSetUnavailable">
确认改不可用
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="platformDialogVisible"
title="改平台"
width="420px"
append-to-body
>
<el-form label-width="84px">
<el-form-item label="提取平台">
<el-select v-model="platformForm.platform" style="width: 100%">
<el-option
v-for="(v, k) in PLATFORM_MAP"
:key="k"
:label="v.label"
:value="k"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="platformDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saveLoading" @click="onUpdatePlatform">
确认修改
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="unextractDialogVisible"
title="反提取"
width="420px"
append-to-body
>
<el-alert type="warning" :closable="false">
反提取会把账号恢复为未提取,并清空提取时间与提取平台。
</el-alert>
<template #footer>
<el-button @click="unextractDialogVisible = false">取消</el-button>
<el-button type="warning" :loading="saveLoading" @click="onUnextract">
确认反提取
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="remarkDialogVisible"
title="改备注"
width="520px"
append-to-body
>
<el-input
v-model="remarkText"
type="textarea"
:rows="5"
resize="none"
placeholder="请输入备注"
/>
<template #footer>
<el-button @click="remarkDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saveLoading" @click="onSaveRemark">
保存备注
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
:deep(.pool-detail-dialog) {
max-width: calc(100vw - 28px);
border-radius: 18px;
overflow: hidden;
}
:deep(.pool-detail-dialog .el-dialog__header) {
padding: 18px 22px;
margin: 0;
border-bottom: 1px solid #eef0f5;
}
:deep(.pool-detail-dialog .el-dialog__body) {
padding: 18px 22px 22px;
background: #f6f8fb;
}
:deep(.el-tag) {
border-radius: 4px !important;
}
.detail-header,
.header-actions,
.section-head,
.copy-actions {
display: flex;
align-items: center;
}
.detail-header {
justify-content: space-between;
gap: 14px;
}
.detail-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
}
.detail-subtitle,
.section-subtitle {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
.header-actions {
gap: 10px;
}
.header-actions .el-button {
font-size: 18px;
font-weight: 300;
}
.detail-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.section-card,
.info-card {
background: #fff;
border: 1px solid #edf0f6;
box-shadow: 0 10px 28px rgba(31, 41, 55, 0.06);
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.info-card {
border-radius: 14px;
padding: 14px;
min-width: 0;
}
.info-label {
color: #909399;
font-size: 12px;
margin-bottom: 8px;
}
.info-value {
color: #303133;
font-size: 14px;
font-weight: 600;
word-break: break-all;
min-height: 20px;
}
.section-card {
border-radius: 16px;
padding: 16px;
}
.section-head {
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.section-title {
font-size: 15px;
font-weight: 700;
color: #303133;
}
.token-box {
margin: 0;
padding: 14px;
max-height: 220px;
overflow: auto;
border-radius: 12px;
background: #111827;
color: #d1e7ff;
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
}
.copy-actions {
flex-wrap: wrap;
gap: 10px;
}
.copy-actions .el-button {
margin: 0;
}
.remark-display {
padding: 12px;
min-height: 42px;
border-radius: 10px;
background: #f8fafc;
color: #303133;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
@media (max-width: 768px) {
:deep(.pool-detail-dialog) {
width: calc(100vw - 24px) !important;
margin: 0 auto;
}
:deep(.pool-detail-dialog .el-dialog__header) {
padding: 14px 14px;
}
:deep(.pool-detail-dialog .el-dialog__body) {
padding: 12px;
max-height: 76vh;
overflow-y: auto;
}
.detail-header,
.section-head {
align-items: flex-start;
}
.section-head {
flex-direction: column;
}
.info-grid {
grid-template-columns: 1fr;
}
.copy-actions .el-button,
.section-head .el-button {
width: 100%;
}
}
</style>
@@ -0,0 +1,259 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { ElMessage } from 'element-plus';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
mode: {
type: String,
default: 'single', // single | batch
},
});
const emit = defineEmits(['update:modelValue', 'submit']);
const form = reactive({
type: 'account', // account | tk | account_tk
account: '',
password: '',
token: '',
batchText: '',
remark: '',
});
const isBatch = computed(() => props.mode === 'batch');
const dialogTitle = computed(() => {
return isBatch.value ? '批量添加账号' : '添加账号';
});
const formatExample = computed(() => {
if (form.type === 'account') {
return 'account,password';
}
if (form.type === 'account_tk') {
return 'account,password,token';
}
return 'token';
});
watch(
() => props.modelValue,
(visible) => {
if (!visible) return;
resetForm();
}
);
function closeDialog() {
emit('update:modelValue', false);
}
function resetForm() {
form.type = 'account';
form.account = '';
form.password = '';
form.token = '';
form.batchText = '';
form.remark = '';
}
function parseBatchRows() {
const rows = form.batchText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const parsed = [];
const errors = [];
rows.forEach((line, index) => {
if (form.type === 'account') {
const [account, password] = line.split(',').map((x) => (x || '').trim());
if (!account || !password) {
errors.push(`${index + 1} 行格式错误,应为 account,password`);
return;
}
parsed.push({
type: 'account',
account,
password,
token: '',
remark: form.remark,
});
return;
}
if (form.type === 'account_tk') {
const [account, password, token] = line
.split(',')
.map((x) => (x || '').trim());
if (!account || !token) {
errors.push(`${index + 1} 行格式错误,应为 account,password,tokenpassword 可为空)`);
return;
}
parsed.push({
type: 'account_tk',
account,
password,
token,
remark: form.remark,
});
return;
}
parsed.push({
type: 'tk',
account: '',
password: '',
token: line,
remark: form.remark,
});
});
return { parsed, errors };
}
function handleSubmit() {
if (!isBatch.value) {
if (form.type === 'account') {
if (!form.account || !form.password) {
return;
}
emit('submit', {
mode: 'single',
rows: [
{
type: 'account',
account: form.account.trim(),
password: form.password.trim(),
token: '',
remark: form.remark.trim(),
},
],
});
closeDialog();
return;
}
if (form.type === 'account_tk') {
if (!form.account || !form.token) {
ElMessage.warning('请输入账号和 Token,密码可为空');
return;
}
emit('submit', {
mode: 'single',
rows: [
{
type: 'account_tk',
account: form.account.trim(),
password: form.password.trim(),
token: form.token.trim(),
remark: form.remark.trim(),
},
],
});
closeDialog();
return;
}
if (!form.token) return;
emit('submit', {
mode: 'single',
rows: [
{
type: 'tk',
account: '',
password: '',
token: form.token.trim(),
remark: form.remark.trim(),
},
],
});
closeDialog();
return;
}
const { parsed, errors } = parseBatchRows();
if (errors.length || parsed.length === 0) {
ElMessage.warning(errors[0] || '请填写批量内容');
return;
}
emit('submit', {
mode: 'batch',
rows: parsed,
});
closeDialog();
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="640px"
@close="closeDialog"
@update:model-value="(v) => emit('update:modelValue', v)"
>
<el-form label-width="96px">
<el-form-item label="账号类型">
<el-radio-group v-model="form.type">
<el-radio value="account">账号密码</el-radio>
<el-radio value="account_tk">账号密码+Token</el-radio>
<el-radio value="tk">Token</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="!isBatch">
<template v-if="form.type === 'account' || form.type === 'account_tk'">
<el-form-item label="账号">
<el-input v-model="form.account" placeholder="请输入账号" clearable />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" placeholder="请输入密码" clearable />
</el-form-item>
<el-form-item v-if="form.type === 'account_tk'" label="Token">
<el-input
v-model="form.token"
type="textarea"
:rows="4"
placeholder="请输入 token"
clearable
/>
</el-form-item>
</template>
<el-form-item v-else label="Token">
<el-input
v-model="form.token"
type="textarea"
:rows="4"
placeholder="请输入 token"
clearable
/>
</el-form-item>
</template>
<el-form-item v-else label="批量内容">
<el-input
v-model="form.batchText"
type="textarea"
:rows="12"
:placeholder="`每行一条,格式:${formatExample}`"
clearable
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" placeholder="可选备注" clearable />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="handleSubmit">确认</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,103 @@
<script setup>
const props = defineProps({
modelValue: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
type: { type: String, default: 'account' },
platform: { type: String, default: 'local' },
remark: { type: String, default: '' },
replenish: { type: Boolean, default: false },
platformMap: { type: Object, default: () => ({}) },
});
const emit = defineEmits([
'update:modelValue',
'update:platform',
'update:remark',
'update:replenish',
'confirm',
]);
function typeText(type) {
if (type === 'account') return '账号密码';
if (type === 'account_tk') return '账号密码+Token';
return 'Token';
}
</script>
<template>
<el-dialog
class="pool-extract-dialog"
:model-value="modelValue"
title="提取账号"
width="90%"
@update:model-value="(v) => emit('update:modelValue', v)"
>
<el-form label-width="84px">
<el-form-item label="提取类型">
<el-input :model-value="typeText(type)" disabled />
</el-form-item>
<el-form-item label="是否补号">
<el-switch
:model-value="replenish"
active-text=""
inactive-text=""
@update:model-value="(v) => emit('update:replenish', v)"
/>
</el-form-item>
<el-form-item label="提取平台">
<el-select
:model-value="platform"
style="width: 100%"
@update:model-value="(v) => emit('update:platform', v)"
>
<el-option v-for="(v, k) in platformMap" :key="k" :value="k" :label="v.label" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input
:model-value="remark"
type="textarea"
:rows="3"
placeholder="提取备注可选"
@update:model-value="(v) => emit('update:remark', v)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="emit('confirm')">确认提取</el-button>
</template>
</el-dialog>
</template>
<style scoped>
:deep(.pool-extract-dialog) {
max-width: 420px;
}
@media (max-width: 768px) {
:deep(.pool-extract-dialog) {
width: calc(100vw - 24px) !important;
margin: 0 auto;
}
:deep(.pool-extract-dialog .el-dialog__body) {
padding: 12px;
}
:deep(.pool-extract-dialog .el-form-item__label) {
width: 74px !important;
}
:deep(.pool-extract-dialog .el-dialog__footer .el-button) {
width: calc(50% - 6px);
margin: 0;
}
:deep(.pool-extract-dialog .el-dialog__footer) {
display: flex;
gap: 12px;
padding: 8px 12px 12px;
}
}
</style>
@@ -0,0 +1,49 @@
<script setup>
const props = defineProps({
modelValue: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
type: { type: String, default: 'tk' },
platform: { type: String, default: 'local' },
remark: { type: String, default: '' },
platformMap: { type: Object, default: () => ({}) },
});
const emit = defineEmits(['update:modelValue', 'update:type', 'update:platform', 'update:remark', 'confirm']);
</script>
<template>
<el-dialog
:model-value="modelValue"
title="补号"
width="420px"
@update:model-value="(v) => emit('update:modelValue', v)"
>
<el-form label-width="84px">
<el-form-item label="账号类型">
<el-select :model-value="type" style="width: 100%" @update:model-value="(v) => emit('update:type', v)">
<el-option label="Token" value="tk" />
<el-option label="账号密码" value="account" />
<el-option label="账号密码+Token" value="account_tk" />
</el-select>
</el-form-item>
<el-form-item label="提取平台">
<el-select :model-value="platform" style="width: 100%" @update:model-value="(v) => emit('update:platform', v)">
<el-option v-for="(v, k) in platformMap" :key="k" :value="k" :label="v.label" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input
:model-value="remark"
type="textarea"
:rows="3"
placeholder="提取备注可选"
@update:model-value="(v) => emit('update:remark', v)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="warning" :loading="loading" @click="emit('confirm')">确认补号</el-button>
</template>
</el-dialog>
</template>
File diff suppressed because it is too large Load Diff
@@ -1,241 +0,0 @@
<template>
<div class="content-stats-container">
<el-row :gutter="20" class="stat-cards">
<el-col :span="6" v-for="card in topCards" :key="card.label">
<el-card shadow="never" class="stat-card">
<div class="label">{{ card.label }}</div>
<div class="count">{{ card.count.toLocaleString() }}</div>
<div class="sub-info" v-if="card.yesterday !== undefined">
昨日 {{ card.yesterday || 0 }}
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="main-charts">
<el-col :span="16">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>内容发布活跃度</span>
<el-radio-group v-model="timeRange" size="small">
<el-radio-button label="week">近一周</el-radio-button>
<el-radio-button label="month">近一月</el-radio-button>
</el-radio-group>
</div>
</template>
<div ref="barChartRef" class="chart-box"></div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover" header="内容分类占比">
<div ref="categoryChartRef" class="chart-box"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="bottom-row">
<el-col :span="24">
<el-card shadow="hover" header="热门内容TOP 5">
<el-table :data="hotContent" style="width: 100%" stripe>
<el-table-column type="index" label="排名" width="80" />
<el-table-column prop="title" label="标题" show-overflow-tooltip />
<el-table-column prop="cate" label="分类" width="120">
<template #default="{ row }">
<el-tag>{{ row.cate || '未分类' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="views" label="阅读量" sortable />
<el-table-column prop="likes" label="点赞数" width="120" />
<el-table-column prop="publish_date" label="发布时间" width="180">
<template #default="{ row }">
{{ row.publish_date ? row.publish_date.slice(0, 16).replace('T', ' ') : '-' }}
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, shallowRef } from "vue";
import * as echarts from "echarts";
import { getContentStats } from "@/api/analytics";
interface TopCard {
label: string;
count: number;
yesterday?: number;
}
interface HotArticle {
id: number;
title: string;
cate: string;
views: number;
likes: number;
publish_date: string;
status: number;
}
const timeRange = ref("week");
const barChartRef = ref<HTMLElement | null>(null);
const categoryChartRef = ref<HTMLElement | null>(null);
const barChart = shallowRef<echarts.ECharts | null>(null);
const categoryChart = shallowRef<echarts.ECharts | null>(null);
const topCards = ref<TopCard[]>([
{ label: "总发布量", count: 0 },
{ label: "本月新增", count: 0 },
{ label: "总点赞量", count: 0 },
{ label: "总访问量", count: 0 },
]);
const hotContent = ref<HotArticle[]>([]);
async function fetchContentStats() {
const res = await getContentStats();
if (res.code === 200 && res.data) {
const { total_articles, month_articles, total_likes, total_views, hot_articles } = res.data;
topCards.value = [
{ label: "总发布量", count: total_articles || 0 },
{ label: "本月新增", count: month_articles || 0 },
{ label: "总点赞量", count: total_likes || 0 },
{ label: "总访问量", count: total_views || 0 },
];
hotContent.value = hot_articles || [];
}
}
// --- 图表初始化 ---
const initCharts = () => {
// 柱状图:发布趋势
if (barChartRef.value) {
barChart.value = echarts.init(barChartRef.value);
barChart.value.setOption({
tooltip: { trigger: "axis" },
xAxis: {
type: "category",
data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
axisLine: { lineStyle: { color: "#DCDFE6" } },
},
yAxis: { type: "value" },
series: [
{
name: "发布篇数",
data: [45, 52, 38, 65, 48, 23, 31],
type: "bar",
barWidth: "40%",
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#83bff6" },
{ offset: 0.5, color: "#188df0" },
{ offset: 1, color: "#188df0" },
]),
},
},
],
});
}
// 环形图:分类占比
if (categoryChartRef.value) {
categoryChart.value = echarts.init(categoryChartRef.value);
categoryChart.value.setOption({
tooltip: { trigger: "item" },
series: [
{
type: "pie",
radius: ["50%", "70%"],
data: [
{ value: 40, name: "技术文章" },
{ value: 30, name: "行业资讯" },
{ value: 20, name: "视频教程" },
{ value: 10, name: "资源分享" },
],
emphasis: {
label: { show: true, fontSize: "16", fontWeight: "bold" },
},
label: { show: false, position: "center" },
},
],
});
}
};
const handleResize = () => {
barChart.value?.resize();
categoryChart.value?.resize();
};
onMounted(async () => {
await fetchContentStats();
initCharts();
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
</script>
<style scoped lang="less">
.content-stats-container {
min-height: 100vh;
.stat-cards {
margin-bottom: 24px;
.stat-card {
border: none;
border-radius: 8px;
.label {
color: #8c8c8c;
font-size: 14px;
margin-bottom: 8px;
}
.count {
font-size: 28px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 8px;
}
.sub-info {
font-size: 12px;
color: #8c8c8c;
.plus {
color: #52c41a;
}
.minus {
color: #f5222d;
}
}
}
}
.main-charts {
margin-bottom: 24px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-box {
height: 320px;
}
}
.bottom-row {
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
}
}
}
</style>
@@ -1,493 +0,0 @@
<template>
<div class="category-manager">
<!-- 页面头部 -->
<div class="page-header">
<div class="page-title">
<h3>文章分类</h3>
<p> {{ totalCount }} 个分类</p>
</div>
<div class="page-actions">
<el-button type="primary" :icon="Plus" @click="handleAdd">
新增分类
</el-button>
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
</div>
</div>
<div class="category-list" v-loading="loading">
<div v-if="filteredCategories.length === 0" class="empty-state">
<el-empty description="暂无文章分类" />
</div>
<div v-else class="category-tree">
<category-node
v-for="category in filteredCategories"
:key="category.id"
:item="category"
:level="0"
@edit="handleEdit"
@add-child="handleAddChild"
@delete="handleDelete"
/>
</div>
</div>
<edit-cate
v-model="dialogVisible"
:category="currentEdit"
@saved="fetchCategories"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
Plus,
Edit,
Delete,
Check,
Close,
Search,
Refresh,
Document,
Picture,
ArrowDown,
ArrowRight,
} from "@element-plus/icons-vue";
import { allCategories, deleteCategory } from "@/api/article";
import EditCate from "@/views/apps/cms/articles/components/edit-cate.vue";
import CategoryNode from "./components/CategoryNode.vue";
// 颜色预设
// 响应式数据
const loading = ref(false);
const dialogVisible = ref(false);
const currentEdit = ref(null);
const searchText = ref("");
const categories = ref([]);
// 新增分类
const addCategory = ref(null);
const handleAdd = () => {
addCategory.value = { parentId: 0 };
dialogVisible.value = true;
}
// 计算属性
const totalCount = computed(() => categories.value.length);
const filteredCategories = computed(() => {
if (!searchText.value) {
return categories.value;
}
const search = searchText.value.toLowerCase();
return categories.value.filter(
(category) =>
category.label.toLowerCase().includes(search) ||
(category.remark && category.remark.toLowerCase().includes(search)),
);
});
// 工具函数
function buildTree(data) {
// 1. 标准化数据字段
const transformed = data.map((item) => ({
...item,
label: item.name ?? item.label ?? "",
remark: item.desc ?? item.remark ?? "",
parentId: item.parentId ?? item.cid ?? 0,
children: [],
expanded: true, // 默认展开所有级
}));
const map = new Map();
transformed.forEach((item) => map.set(item.id, item));
const roots = [];
transformed.forEach((item) => {
const parent = map.get(item.parentId);
if (parent) {
parent.children.push(item);
} else {
// 如果没有父节点,或者是顶级节点 (parentId 为 0)
roots.push(item);
}
});
return roots;
}
// 事件处理
function handleCreate() {
currentEdit.value = null;
dialogVisible.value = true;
}
function handleEdit(category) {
currentEdit.value = category;
dialogVisible.value = true;
}
function handleAddChild(parent) {
currentEdit.value = { parentId: parent.id };
dialogVisible.value = true;
}
function handleRefresh() {
searchText.value = "";
fetchCategories();
}
async function handleSearch() {
await fetchCategories();
}
function clearSearch() {
searchText.value = "";
fetchCategories();
}
function toggleExpand(category) {
category.expanded = !category.expanded;
}
function handleDelete(category) {
ElMessageBox.confirm(`确定要删除分类\"${category.label}\"吗?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
deleteCategory(category.id)
.then(() => {
ElMessage.success("删除成功");
fetchCategories();
})
.catch((error) => {
console.error("删除分类失败:", error);
ElMessage.error("删除失败");
});
});
}
// API调用
async function fetchCategories() {
try {
loading.value = true;
const response = await allCategories({ keyword: searchText.value });
if (response.code === 200) {
const categoryList = response.data || [];
// 这里会递归生成三级、四级等
categories.value = buildTree(categoryList);
}
} catch (error) {
ElMessage.error("获取失败");
} finally {
loading.value = false;
}
}
// 生命周期
onMounted(() => {
fetchCategories();
});
</script>
<style lang="less" scoped>
.category-manager {
min-height: 100vh;
padding: 20px;
background-color: var(--el-bg-color-page);
// 页面头部样式
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
background: var(--el-bg-color);
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.page-title {
h3 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
}
p {
margin: 0;
color: #86909c;
font-size: 14px;
}
}
.page-actions {
display: flex;
gap: 12px;
.el-button {
border-radius: 6px;
font-weight: 500;
.el-icon {
margin-right: 6px;
}
}
}
}
// 搜索栏样式
.search-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
background: var(--el-bg-color);
padding: 16px 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.search {
display: flex;
gap: 12px;
}
.el-input {
border-radius: 6px;
}
.search-stats {
color: #86909c;
font-size: 14px;
}
}
// 分类列表容器
.category-list {
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
overflow: hidden;
.empty-state {
padding: 80px 40px;
.empty-icon {
color: #c9cdd4;
}
.el-button {
border-radius: 6px;
font-weight: 500;
}
}
// 递归树结构核心样式
.category-tree {
.category-item {
border-bottom: 1px solid var(--el-border-color-lighter);
transition: all 0.2s;
&:last-child {
border-bottom: none;
}
// 禁用状态
&.disabled {
.category-main {
opacity: 0.6;
background-color: var(--el-fill-color-lightest);
}
}
// 层级背景区分 (可选:让子级背景稍微深一点点)
&.child-item {
.category-main {
background-color: rgba(245, 247, 250, 0.2);
}
}
.category-main {
display: flex;
align-items: center;
padding: 14px 24px; // 左右 padding 保持,左侧缩进通过内联 style 控制
transition: background-color 0.2s;
position: relative;
&:hover {
background-color: var(--el-fill-color-light) !important;
}
// 展开收起按钮占位
.expand-btn,
.expand-spacer {
width: 28px;
flex-shrink: 0;
}
.expand-btn {
.expand-button {
padding: 4px;
color: var(--el-text-color-placeholder);
&:hover {
color: var(--el-color-primary);
}
}
}
// 分类具体内容
.category-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 12px;
flex-shrink: 0;
}
.category-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background-color: var(--el-fill-color-light);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
.el-icon {
font-size: 16px;
color: var(--el-text-color-regular);
}
}
.category-text {
flex: 1;
min-width: 0;
.category-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
.name {
font-weight: 600;
font-size: 14px;
color: var(--el-text-color-primary);
}
.status-tag {
font-size: 10px;
height: 20px;
padding: 0 6px;
}
}
.category-desc {
color: var(--el-text-color-placeholder);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
// 操作按钮组
.category-actions {
flex-shrink: 0;
margin-left: 16px;
opacity: 0.4; // 默认低透明度,鼠标悬浮时高亮
transition: opacity 0.2s;
.el-button-group .el-button {
padding: 8px;
border-radius: 4px;
margin-left: 4px;
&.danger:hover {
color: var(--el-color-danger);
}
&:hover {
color: var(--el-color-primary);
}
}
}
// 鼠标悬停时显示按钮
&:hover .category-actions {
opacity: 1;
}
}
}
// 子分类容器样式
.category-children {
margin-left: 0;
.category-node-wrapper {
margin-top: 0;
}
}
}
}
}
// Element Plus 对话框深度样式美化
:deep(.el-dialog) {
border-radius: 12px;
.el-dialog__header {
padding: 20px 24px;
margin: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.el-dialog__title {
font-size: 16px;
font-weight: 600;
}
.el-dialog__body {
padding: 24px;
}
.el-dialog__footer {
padding: 16px 24px 24px;
}
.el-form-item__label {
font-weight: 500;
}
.el-input__inner,
.el-textarea__inner {
border-radius: 6px;
}
}
// 移动端适配
@media (max-width: 768px) {
.category-manager {
padding: 12px;
.page-header {
flex-direction: column;
gap: 16px;
padding: 16px;
.page-actions {
width: 100%;
}
}
.search-bar {
flex-direction: column;
gap: 12px;
padding: 16px;
}
.category-list .category-tree .category-item .category-main {
padding: 12px !important; // 移动端取消大缩进,改用其他视觉暗示
.category-actions {
opacity: 1;
}
.category-desc {
display: none;
} // 隐藏描述节省空间
}
}
}
</style>
@@ -1,269 +0,0 @@
<template>
<div class="category-node-wrapper">
<div
class="category-item"
:class="{ 'child-item': level > 0, disabled: !item.status }"
>
<div
class="category-main"
:style="{ paddingLeft: (level * 24 + 20) + 'px' }"
@click="handleRowClick"
>
<div class="expand-btn" v-if="item.children && item.children.length > 0">
<el-button
type="text"
size="small"
@click="toggleExpand"
class="expand-button"
>
<el-icon>
<component :is="item.expanded ? 'ArrowDown' : 'ArrowRight'" />
</el-icon>
</el-button>
</div>
<div class="expand-spacer" v-else></div>
<div class="category-info">
<div class="color-dot" :style="{ backgroundColor: item.color }"></div>
<div class="category-icon">
<el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
<el-icon v-else class="default-icon"><Document /></el-icon>
</div>
<div class="category-text">
<div class="category-title">
<span class="name">{{ item.label }}</span>
<el-tag
:type="item.status ? 'success' : 'danger'"
size="small"
class="status-tag"
>
{{ item.status ? "启用" : "禁用" }}
</el-tag>
</div>
<div class="category-desc" v-if="item.remark">{{ item.remark }}</div>
</div>
</div>
<div class="category-actions">
<el-button-group size="small">
<el-button type="text" title="编辑" @click.stop="$emit('edit', item)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button type="text" title="添加子分类" @click.stop="$emit('add-child', item)">
<el-icon><Plus /></el-icon>
</el-button>
<el-button type="text" class="danger" title="删除" @click.stop="$emit('delete', item)">
<el-icon><Delete /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
</div>
<div
v-if="item.children && item.children.length > 0 && item.expanded"
class="category-children"
>
<category-node
v-for="child in item.children"
:key="child.id"
:item="child"
:level="level + 1"
@edit="$emit('edit', $event)"
@add-child="$emit('add-child', $event)"
@delete="$emit('delete', $event)"
/>
</div>
</div>
</template>
<script setup>
import { ArrowDown, ArrowRight, Edit, Plus, Delete, Document } from "@element-plus/icons-vue";
const props = defineProps({
item: { type: Object, required: true },
level: { type: Number, default: 0 }
});
const emit = defineEmits(['edit', 'add-child', 'delete']);
const toggleExpand = () => {
props.item.expanded = !props.item.expanded;
};
const handleRowClick = () => {
if (props.item.children && props.item.children.length > 0) {
toggleExpand();
}
};
</script>
<style lang="less" scoped>
.category-node-wrapper {
.category-item {
border-bottom: 1px solid var(--el-border-color-lighter);
transition: all 0.2s;
&:last-child {
border-bottom: none;
}
&.disabled {
.category-main {
opacity: 0.6;
background-color: var(--el-fill-color-lightest);
}
}
&.child-item {
.category-main {
background-color: rgba(245, 247, 250, 0.2);
}
}
.category-main {
display: flex;
align-items: center;
padding: 14px 24px;
transition: background-color 0.2s;
position: relative;
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-light) !important;
}
.expand-btn,
.expand-spacer {
width: 28px;
flex-shrink: 0;
}
.expand-btn {
.expand-button {
padding: 4px;
color: var(--el-text-color-placeholder);
&:hover {
color: var(--el-color-primary);
}
}
}
.category-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 12px;
flex-shrink: 0;
}
.category-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background-color: var(--el-fill-color-light);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
.el-icon {
font-size: 16px;
color: var(--el-text-color-regular);
}
}
.category-text {
flex: 1;
min-width: 0;
.category-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
.name {
font-weight: 600;
font-size: 14px;
color: var(--el-text-color-primary);
}
.status-tag {
font-size: 10px;
height: 20px;
padding: 0 6px;
}
}
.category-desc {
color: var(--el-text-color-placeholder);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.category-actions {
flex-shrink: 0;
margin-left: 16px;
opacity: 0.4;
transition: opacity 0.2s;
.el-button-group .el-button {
padding: 8px;
border-radius: 4px;
margin-left: 4px;
&.danger:hover {
color: var(--el-color-danger);
}
&:hover {
color: var(--el-color-primary);
}
}
}
&:hover .category-actions {
opacity: 1;
}
}
}
.category-children {
.category-node-wrapper {
margin-top: 0;
}
}
}
@media (max-width: 768px) {
.category-node-wrapper {
.category-item {
.category-main {
padding: 12px !important;
.category-actions {
opacity: 1;
}
.category-desc {
display: none;
}
}
}
}
}
</style>
@@ -1,324 +0,0 @@
<template>
<el-dialog v-model="visibleInternal" :title="dialogTitle" width="600px" :close-on-click-modal="false"
@close="closeDialog">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="分类名称" prop="label">
<el-input v-model="formData.label" placeholder="请输入分类名称" clearable />
</el-form-item>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="父级分类" prop="parentId">
<el-tree-select v-model="formData.parentId" :data="treeData"
:props="{ label: 'label', value: 'id', children: 'children' }" placeholder="选择父级分类(可选)" clearable
filterable check-strictly style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" controls-position="right"
style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="默认图片" prop="image">
<div class="uploads">
<el-upload v-model:file-list="fileList" :auto-upload="false" :before-upload="beforeImgUpload" list-type="picture-card" :limit="1" :on-change="handleUploadChange">
<el-icon>
<Plus />
</el-icon>
<template #file="{ file }">
<div>
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="" />
<span class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview" @click="handlePictureCardPreview(file)">
<el-icon>
<ZoomIn />
</el-icon>
</span>
<span class="el-upload-list__item-delete" @click="handleRemove(file)">
<el-icon>
<Delete />
</el-icon>
</span>
</span>
</div>
</template>
</el-upload>
<el-dialog v-model="dialogVisible">
<img w-full :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
<div class="upload-tip">
<span>建议尺寸250px × 140px</span>
</div>
</div>
</el-form-item>
<el-form-item label="分类描述" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="4" placeholder="请输入分类描述..." maxlength="200"
show-word-limit />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { Plus, ZoomIn, Delete } from '@element-plus/icons-vue'
import { ElMessage, ElUpload } from 'element-plus'
import { createCategory, editCategory, listCategories } from '@/api/article'
import { uploadFile } from '@/api/file.js'
// Props
const props = defineProps({
modelValue: { type: Boolean, default: false },
category: { type: Object as () => any | null, default: null }, // 传入的分类对象,null 表示新增
})
// Emits
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
// 可见性同步
const visibleInternal = ref(props.modelValue)
watch(() => props.modelValue, v => (visibleInternal.value = v))
watch(visibleInternal, v => {
emit('update:modelValue', v)
if (v) loadTreeData()
})
// 数据加载:父级分类树
const treeData = ref<any[]>([])
watch(treeData, () => {
// 触发tree-select重新渲染已选 label
formData.parentId = formData.parentId ?? 0
})
async function loadTreeData() {
const res = await listCategories({ page: 1, pageSize: 1000 })
if (res.code === 200) {
const list = res.data?.records || res.data || []
treeData.value = buildTree(list)
}
}
// 构建树形
function buildTree(data: any[]) {
const map = new Map()
const roots: any[] = []
data.forEach((item) => {
map.set(item.id, { ...item, label: item.name ?? item.label, children: [] })
})
map.forEach((item: any) => {
if (item.cid && map.has(item.cid)) {
map.get(item.cid).children.push(item)
} else {
roots.push(item)
}
})
return [{ id: 0, label: '顶级', children: roots }]
}
// 表单
const formRef = ref()
const formData = reactive({
id: null as number | null,
label: '',
image: '',
remark: '',
sort: 0,
status: 1,
parentId: 0 as number,
})
function resetForm() {
if (formRef.value) formRef.value.clearValidate()
Object.assign(formData, {
id: null,
label: '',
image: '',
remark: '',
sort: 0,
status: 1,
parentId: 0,
})
}
// 当传入 category 变化时同步
watch(
() => props.category,
(val) => {
if (val) {
Object.assign(formData, {
id: val.id,
label: val.label,
image: val.image,
remark: val.remark,
sort: val.sort,
status: val.status,
parentId: val.parentId ?? val.cid ?? 0,
})
} else {
resetForm()
}
},
{ immediate: true }
)
// 上传相关
const fileList = ref<any[]>([])
const dialogVisible = ref(false)
const dialogImageUrl = ref('')
function beforeImgUpload(file: File) {
const isImage = file.type.startsWith('image/')
const isLt10M = file.size / 1024 / 1024 < 10
if (!isImage) ElMessage.error('仅支持图片格式')
if (!isLt10M) ElMessage.error('图片大小不能超过10MB')
return isImage && isLt10M
}
function handleRemove(file: any) {
fileList.value = []
formData.image = ''
}
async function handleUploadChange(file: any) {
if (file.raw) {
const isImage = file.raw.type.startsWith('image/')
const isLt10M = file.raw.size / 1024 / 1024 < 10
if (!isImage || !isLt10M) {
fileList.value = []
ElMessage.error(isImage ? '图片大小不能超过10MB' : '仅支持图片格式')
return
}
}
}
function handlePictureCardPreview(file: any) {
dialogImageUrl.value = file.url
dialogVisible.value = true
}
// 校验
const formRules = {
label: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 2, max: 50, message: '分类名称长度在 2 到 50 个字符', trigger: 'blur' },
],
remark: [{ max: 200, message: '描述不能超过200个字符', trigger: 'blur' }],
}
const isEdit = computed(() => !!formData.id)
// 预览对话框
const dialogTitle = computed(() => (isEdit.value ? '编辑分类' : '新增分类'))
const submitLoading = ref(false)
async function handleSubmit() {
formRef.value.validate(async (valid: boolean) => {
if (!valid) return
try {
submitLoading.value = true
let imageUrl = formData.image
if (fileList.value.length > 0 && fileList.value[0].raw) {
const uploadFormData = new FormData()
uploadFormData.append('file', fileList.value[0].raw)
uploadFormData.append('cate', 'category')
const uploadRes = await uploadFile(uploadFormData)
if (uploadRes?.data?.url) {
imageUrl = uploadRes.data.url
}
}
const payload = {
name: formData.label,
image: imageUrl,
desc: formData.remark,
sort: formData.sort,
status: formData.status,
cid: formData.parentId,
}
if (isEdit.value) {
await editCategory(formData.id, payload)
ElMessage.success('更新成功')
} else {
await createCategory(payload)
ElMessage.success('创建成功')
}
emit('saved')
closeDialog()
} catch (err) {
console.error(err)
ElMessage.error(isEdit.value ? '更新失败' : '创建失败')
} finally {
submitLoading.value = false
}
})
}
function closeDialog() {
visibleInternal.value = false
resetForm()
}
</script>
<style scoped>
/* 如果需要额外样式,可在此处编写 */
.avatar-uploader {
.el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.avatar {
width: 80px;
height: 80px;
display: block;
border-radius: 6px;
}
.avatar-uploader-icon {
font-size: 32px;
color: #8c939d;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
}
.uploads{
display: flex;
flex-direction: column;
}
.upload-tip {
font-size: 12px;
color: #999;
}
</style>
@@ -1,686 +0,0 @@
<template>
<el-drawer
v-model="visible"
:title="isEdit ? '编辑文章' : '新增文章'"
size="60%"
:before-close="handleBeforeClose"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入文章标题" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="分类" prop="cate">
<el-cascader
v-model="form.cate"
:options="cateOptions"
placeholder="选择分类"
clearable
:props="{ expandTrigger: 'hover', emitPath: false }"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="作者" prop="author">
<el-input v-model="form.author" placeholder="请输入作者姓名" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="简介" prop="desc">
<el-input
v-model="form.desc"
:rows="4"
type="textarea"
placeholder="请输入简介"
/>
</el-form-item>
<el-form-item label="封面图片" prop="image">
<div class="uploads">
<!-- 已有图片显示 -->
<div
v-if="form.image && fileList.length === 0"
class="existing-image"
>
<img
:src="API_BASE_URL + form.image.replace(/\\\//g, '/')"
alt="已有图片"
/>
<div class="image-actions">
<el-button
type="primary"
size="small"
@click="previewExistingImage"
>预览</el-button
>
<el-button type="danger" size="small" @click="removeExistingImage"
>删除</el-button
>
</div>
</div>
<!-- 上传组件 -->
<el-upload
v-model:file-list="fileList"
:auto-upload="false"
:before-upload="beforeImgUpload"
list-type="picture-card"
:limit="1"
:show-file-list="fileList.length > 0"
>
<el-icon>
<Plus />
</el-icon>
<template #file="{ file }">
<div>
<img
class="el-upload-list__item-thumbnail"
:src="file.url"
alt=""
/>
<span class="el-upload-list__item-actions">
<span
class="el-upload-list__item-preview"
@click="handlePictureCardPreview(file)"
>
<el-icon>
<ZoomIn />
</el-icon>
</span>
<span
class="el-upload-list__item-delete"
@click="handleRemove(file)"
>
<el-icon>
<Delete />
</el-icon>
</span>
</span>
</div>
</template>
</el-upload>
<el-drawer v-model="drawerVisible" width="60%" center>
<div
style="
display: flex;
justify-content: center;
align-items: center;
"
>
<img
:src="drawerImageUrl"
alt="Preview Image"
style="max-width: 100%; max-height: 70vh; object-fit: contain"
/>
</div>
</el-drawer>
<div class="upload-tip">
<span>建议尺寸250px × 140px</span>
</div>
</div>
</el-form-item>
<el-divider></el-divider>
<el-form-item label="是否转载" prop="is_trans">
<el-radio-group v-model="form.is_trans">
<el-radio-button :value="0"></el-radio-button>
<el-radio-button :value="1"></el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="发布地址" prop="transurl" v-if="form.is_trans === 1">
<el-input v-model="form.transurl" placeholder="请输入转载文章地址" />
</el-form-item>
<el-form-item label="内容" prop="content">
<div class="editor-container">
<WangEditor v-model="form.content" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-divider></el-divider>
<span class="drawer-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button
v-if="!isEdit"
type="warning"
@click="handleConfirm(0)"
:loading="submitLoading"
>草稿</el-button
>
<el-button
type="primary"
@click="handleConfirm(1)"
:loading="submitLoading"
>提交</el-button
>
</span>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { ref, reactive, watch, nextTick, onMounted, computed } from "vue";
import { ElMessage, ElUpload } from "element-plus";
import WangEditor from "@/views/components/WangEditor.vue";
import { createArticle, editArticle, listCategories } from "@/api/article.js";
import { uploadFile } from "@/api/file.js";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
isEdit: {
type: Boolean,
default: false,
},
model: {
type: Object,
default: () => null,
},
});
const emit = defineEmits(["update:modelValue", "saved"]);
const visible = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
const handleBeforeClose = (done: () => void) => {
handleCancel();
done();
};
const cateOptions = ref([]);
const form = reactive({
title: "",
author: "",
cate: "",
content: "",
image: "",
desc: "",
is_trans: 0,
transurl: null,
});
const rules = {
title: [
{ required: true, message: "请输入文章标题", trigger: "blur" },
{
min: 2,
max: 200,
message: "标题长度在 2 到 200 个字符",
trigger: "blur",
},
],
author: [
{ required: true, message: "请输入作者", trigger: "blur" },
{ max: 50, message: "作者姓名不能超过50个字符", trigger: "blur" },
],
content: [{ required: true, message: "请输入文章内容", trigger: "blur" }],
};
//
async function fetchCategories() {
try {
const res = await listCategories();
if (res.code === 200) {
const categories = Array.isArray(res.data) ? res.data : [];
//
cateOptions.value = buildCategoryTree(categories);
}
} catch (error) {
console.error("获取分类失败:", error);
}
}
//
function buildCategoryTree(categories, parentCid = 0) {
return categories
.filter((cate) => Number(cate.cid) === Number(parentCid))
.map((cate) => {
const children = buildCategoryTree(categories, cate.id);
return {
...cate,
label: cate.name,
value: cate.id,
children: children.length > 0 ? children : undefined,
};
});
}
//
const fileList = ref<any[]>([]);
const drawerVisible = ref(false);
const drawerImageUrl = ref("");
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
function beforeImgUpload(file: File) {
const isImage = file.type.startsWith("image/");
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImage) ElMessage.error("仅支持图片格式");
if (!isLt10M) ElMessage.error("图片大小不能超过10MB");
return isImage && isLt10M;
}
function handlePictureCardPreview(file: any) {
drawerImageUrl.value = file.url;
drawerVisible.value = true;
}
function handleRemove(file: any) {
fileList.value = [];
form.image = "";
}
function removeExistingImage() {
form.image = "";
}
function previewExistingImage() {
const imagePath = form.image.replace(/\\\//g, "/");
drawerImageUrl.value = API_BASE_URL + imagePath;
drawerVisible.value = true;
}
//
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
if (newVal) {
if (props.isEdit && props.model) {
//
const modelData = props.model._raw || props.model;
nextTick(() => {
Object.assign(form, {
title: modelData.title || "",
author: modelData.author || "",
cate: modelData.cate || "",
content: modelData.content || "",
desc: modelData.desc || "",
is_trans: modelData.is_trans || 0,
transurl: modelData.transurl || null,
image: modelData.image || "",
});
fileList.value = []; //
});
} else {
//
resetForm();
}
}
},
);
function handleCancel() {
emit("update:modelValue", false);
resetForm();
}
async function handleConfirm(status = 1) {
formRef.value?.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
let imageUrl = form.image;
//
if (fileList.value.length > 0 && fileList.value[0].raw) {
const uploadFormData = new FormData();
uploadFormData.append("file", fileList.value[0].raw);
uploadFormData.append("cate", "article");
const uploadRes = await uploadFile(uploadFormData);
// 200=201=使
if (
(uploadRes.code === 200 || uploadRes.code === 201) &&
uploadRes.data &&
uploadRes.data.url
) {
imageUrl = uploadRes.data.url;
} else {
ElMessage.error("图片上传失败:" + (uploadRes.msg || "未知错误"));
return;
}
}
// 0稿1
const submitData = {
title: form.title,
author: form.author,
cate: form.cate,
content: form.content,
desc: form.desc,
image: imageUrl,
is_trans: form.is_trans,
transurl: form.is_trans === 1 ? form.transurl : null,
status: status, // 0稿1
};
// /API
const isCreateMode = !props.isEdit;
let res;
let resp;
if (isCreateMode) {
// 使createArticle
const createArticleWithCheck = async (ignoreSimilarity = false) => {
const data = {
...submitData,
...(ignoreSimilarity && { ignore_similarity: 1 }),
};
return await createArticle(data);
};
res = await createArticleWithCheck();
resp =
res && typeof res.code !== "undefined"
? res
: res && res.data
? res.data
: res;
//
if (resp && resp.code === 409) {
const similarArticles = resp.data?.similar_articles || [];
//
const similarArticlesList = similarArticles
.map(
(article: any) =>
`\n<div style="margin: 8px 0; padding: 8px; background: #f5f7fa; border-radius: 4px;">\n<div style="font-weight: bold; color: #303133;">${article.title}</div>\n<div style="color: #606266; font-size: 14px; margin-top: 4px;">相似度:${article.similarity}%</div>\n</div>\n`,
)
.join("");
const confirmMessage = `\n<div>\n\n${similarArticlesList} \n<p style="margin-top: 16px; color: #303133;">是否继续创建?</p>\n</div>\n`;
// 使Element Plusconfirm
const { ElMessageBox } = await import("element-plus");
try {
await ElMessageBox.confirm(confirmMessage, "检测到相似标题", {
confirmButtonText: "继续创建",
cancelButtonText: "取消",
type: "warning",
dangerouslyUseHTMLString: true,
});
//
res = await createArticleWithCheck(true);
resp =
res && typeof res.code !== "undefined"
? res
: res && res.data
? res.data
: res;
} catch (error) {
//
return;
}
}
} else {
// 使editArticle
// ID
const modelData = props.model._raw || props.model;
const articleId = modelData.id;
if (!articleId) {
ElMessage.error("文章ID不存在,无法更新");
return;
}
// IDIDURL
const editData = {
...submitData,
};
res = await editArticle(articleId, editData);
resp =
res && typeof res.code !== "undefined"
? res
: res && res.data
? res.data
: res;
}
if (
resp &&
resp.code === 200 &&
(resp.message === "success" || resp.msg === "success")
) {
ElMessage.success(
status === 0
? "保存草稿成功"
: props.isEdit
? "更新成功"
: "创建成功",
);
emit("update:modelValue", false);
emit("saved");
} else {
ElMessage.error(
(resp && resp.message) ||
(resp && resp.msg) ||
(status === 0
? "保存草稿失败"
: props.isEdit
? "更新失败"
: "创建失败"),
);
}
} catch (error) {
ElMessage.error(
status === 0
? "保存草稿失败"
: props.isEdit
? "更新失败"
: "创建失败",
);
} finally {
submitLoading.value = false;
}
}
});
}
function resetForm() {
//
if (formRef.value) {
formRef.value.resetFields();
}
//
fileList.value = [];
//
Object.assign(form, {
title: "",
author: "",
cate: "",
content: "",
image: "",
desc: "",
is_trans: 0,
transurl: null,
});
}
//
defineExpose({
resetForm: () => {
formRef.value?.resetFields();
},
});
onMounted(() => {
fetchCategories();
});
</script>
<style lang="less" scoped>
.editor-container {
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
&:focus {
outline: none !important;
}
:deep(.w-e-text),
:deep(.w-e-text-container) {
p {
color: #1a1a2e !important;
margin: 0.5em 0 !important;
}
h1, h2, h3, h4, h5, h6 {
color: #1a1a2e !important;
font-weight: 600 !important;
}
a {
color: #3973ff !important;
text-decoration: underline !important;
&:hover {
color: #3973ff !important;
opacity: 0.8 !important;
}
}
code {
background-color: #f5f7fa !important;
color: #1a1a2e !important;
border: 1px solid #e4e7ed !important;
padding: 2px 6px !important;
border-radius: 3px !important;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
}
pre {
background-color: #f5f7fa !important;
border: 1px solid #e4e7ed !important;
color: #1a1a2e !important;
border-radius: 4px !important;
padding: 12px 16px;
margin: 12px 0;
overflow-x: auto;
code {
background-color: transparent !important;
border: none !important;
padding: 0 !important;
}
}
blockquote {
border-left: 4px solid #3973ff !important;
background-color: #f5f7fa !important;
color: #606266 !important;
padding: 0.6em 1.2em !important;
margin: 1em 0 !important;
}
table {
border-collapse: collapse !important;
border: 1px solid #e4e7ed !important;
th, td {
border: 1px solid #e4e7ed !important;
background-color: #ffffff !important;
color: #1a1a2e !important;
padding: 8px 12px;
}
th {
background-color: #f5f7fa !important;
}
}
ul, ol {
color: #1a1a2e !important;
padding-left: 24px;
margin: 8px 0;
}
li {
color: #1a1a2e !important;
line-height: 1.8;
margin: 4px 0;
}
hr {
border-top: 1px solid #e4e7ed !important;
margin: 16px 0;
}
img {
max-width: 100%;
border-radius: 4px;
margin: 8px 0;
}
}
}
.drawer-footer {
text-align: right;
}
.uploads {
display: flex;
gap: 10px;
}
.upload-tip {
font-size: 12px;
color: #999;
}
.existing-image {
position: relative;
width: 148px;
height: 148px;
border: 1px solid #dcdfe6;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
}
.existing-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-actions {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s;
}
.existing-image:hover .image-actions {
opacity: 1;
}
::deep(.el-message-box) {
width: 800px !important;
}
</style>
@@ -1,347 +0,0 @@
<template>
<el-drawer v-model="visible" title="文章预览" size="60%">
<div class="article-preview">
<div class="article-header">
<h1 class="article-title">{{ model?.title || "无标题" }}</h1>
<div class="article-meta">
<span class="meta-item">
<i class="el-icon-price-tag"></i>
<el-tag type="primary">{{ getCategoryName(model?.cate) }}</el-tag>
</span>
<span class="meta-item">
<i class="el-icon-user"></i>
作者{{ model?.author }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
发布人{{ model?.publisher || "暂未发布" }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
阅读量{{ model?.views || 0 }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
收藏量{{ model?.likes || 0 }}
</span>
<span class="meta-item" v-if="model?.publish_time">
<i class="el-icon-time"></i>
{{ formatDate(model.publish_time) }}
</span>
<span class="meta-item">
<i class="el-icon-view"></i>
创建日期{{ model?.create_time }}
</span>
</div>
</div>
<div class="article-content">
<div
v-if="model?.content"
v-html="model.content"
class="content-html"
></div>
<div v-else class="no-content">
<el-empty description="暂无内容"></el-empty>
</div>
</div>
</div>
</el-drawer>
</template>
<script lang="ts" setup>
import { ref, watch, onMounted } from "vue";
import { listCategories } from "@/api/article";
import { ElMessage, ElMessageBox } from "element-plus";
const categoryOptions = ref([]);
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
model: {
type: Object,
default: () => null,
},
});
//
const fetchCategories = async () => {
try {
const res = await listCategories({ page: 1, limit: 1000 });
if (res.code === 200) {
categoryOptions.value = Array.isArray(res.data)
? res.data
: Array.isArray(res.data?.list)
? res.data.list
: [];
} else {
ElMessage.error(res?.msg || "获取分类列表失败");
}
} catch (error) {
ElMessage.error("获取分类列表失败");
}
};
// ID
const getCategoryName = (cate) => {
if (!cate) return "无分类";
//
const cateId = Number(cate);
const category = categoryOptions.value.find(
(item) => Number(item.id) === cateId,
);
return category?.name || "无分类";
};
const emit = defineEmits(["update:modelValue"]);
const visible = ref(false);
//
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
},
);
// visible
watch(visible, (newVal) => {
emit("update:modelValue", newVal);
});
onMounted(() => {
fetchCategories();
});
</script>
<style lang="less" scoped>
.article-preview {
padding: 20px;
.article-header {
border-bottom: 1px solid #ebeef5;
padding-bottom: 20px;
margin-bottom: 20px;
.article-title {
margin: 0 0 15px 0;
font-size: 24px;
font-weight: 600;
color: #303133;
line-height: 1.4;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 15px;
color: #909399;
font-size: 14px;
.meta-item {
display: flex;
align-items: center;
gap: 5px;
i {
font-size: 16px;
}
}
}
}
.article-content {
/* 确保article-content直接子元素的样式 */
& > div {
line-height: 1.8;
color: #606266;
padding: 20px 0;
}
/* 支持所有子元素的内联样式 */
* {
box-sizing: border-box;
}
/* 段落样式 */
p {
margin: 10px 0;
line-height: 1.8;
}
/* 标题样式 */
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 20px 0 10px 0;
font-weight: 600;
}
h1 {
font-size: 28px;
}
h2 {
font-size: 24px;
}
h3 {
font-size: 20px;
}
h4 {
font-size: 18px;
}
h5 {
font-size: 16px;
}
h6 {
font-size: 14px;
}
/* 列表样式 */
ul,
ol {
margin: 10px 0;
padding-left: 30px;
}
/* 引用样式 */
blockquote {
border-left: 4px solid #ebeef5;
padding-left: 15px;
margin: 15px 0;
color: #909399;
font-style: italic;
}
/* 代码样式 */
:deep(code) {
border-radius: 4px;
padding: 2px 6px;
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
font-size: 13px;
color: #f06b6b;
}
/* 代码块样式 */
:deep(pre) {
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d3f 100%);
border-radius: 12px;
padding: 10px;
overflow-x: auto;
margin: 20px 0;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
/* 图片样式 */
img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 10px 0;
}
/* 表格样式 */
table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
th,
td {
border: 1px solid #ebeef5;
padding: 8px 12px;
text-align: left;
}
th {
background-color: #fafafa;
font-weight: 600;
}
}
/* 确保内联样式能够生效 */
[style] {
/* 允许内联样式正常工作 */
all: unset;
/* 重新应用基本样式 */
line-height: 1.8;
color: #606266;
/* 允许特定内联样式覆盖 */
&[style*="text-align"] {
text-align: var(--text-align, inherit) !important;
}
&[style*="color"] {
color: var(--color, inherit) !important;
}
&[style*="font-size"] {
font-size: var(--font-size, inherit) !important;
}
&[style*="font-weight"] {
font-weight: var(--font-weight, inherit) !important;
}
&[style*="font-style"] {
font-style: var(--font-style, inherit) !important;
}
&[style*="text-decoration"] {
text-decoration: var(--text-decoration, inherit) !important;
}
&[style*="margin"] {
margin: var(--margin, inherit) !important;
}
&[style*="padding"] {
padding: var(--padding, inherit) !important;
}
}
/* 直接支持text-align属性 */
[align] {
text-align: attr(align) !important;
}
/* 确保content-html类的样式 */
.content-html {
& > * {
margin: 10px 0;
}
& > h1,
& > h2,
& > h3,
& > h4,
& > h5,
& > h6 {
margin: 20px 0 10px 0;
}
& > p {
margin: 10px 0;
}
/* 支持居中样式 */
& > p[style*="text-align: center"],
& > p[align="center"] {
text-align: center !important;
}
/* 图片居中 */
& > p[style*="text-align: center"] img,
& > p[align="center"] img {
display: inline-block;
margin: 10px auto;
}
}
.no-content {
text-align: center;
padding: 40px 0;
}
}
}
</style>
@@ -1,670 +0,0 @@
<template>
<div class="cms-articles">
<div class="articles-container">
<!-- 顶部操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">新增文章</el-button>
<el-button @click="handleRefresh">刷新</el-button>
<div class="search-bar">
<el-input
v-model="searchQuery"
placeholder="搜索文章标题"
clearable
@clear="handleSearch"
>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
</div>
</div>
<!-- 筛选条件 -->
<div class="filters">
<!-- 根据分类筛选 -->
<el-select
v-model="categoryFilter"
placeholder="选择分类"
clearable
@change="handleFilterChange"
style="width: 150px; margin-right: 10px"
>
<el-option
v-for="item in categoryOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<!-- 根据日期筛选 -->
<el-date-picker
v-model="dateFilter"
type="daterange"
value-format="yyyy-MM-dd"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 240px; margin-right: 10px"
/>
</div>
<!-- 文章列表 -->
<el-table
:data="articleList"
v-loading="loading"
stripe
border
style="width: 100%"
>
<el-table-column
prop="title"
label="标题"
min-width="400"
show-overflow-tooltip
>
<template #default="{ row }">
<div style="display: flex; align-items: center; gap: 4px">
<!-- 置顶标签 -->
<el-tag
v-if="row.top === 1"
type="danger"
size="small"
effect="dark"
>
置顶
</el-tag>
<!-- 推荐标签 -->
<el-tag
v-if="row.recommend === 1"
type="warning"
size="small"
effect="dark"
>
推荐
</el-tag>
<!-- 标题链接 -->
<el-link
type="primary"
@click="handleView(row)"
underline="never"
>
{{ row.title }}
</el-link>
</div>
</template>
</el-table-column>
<el-table-column prop="cate" label="文章分类" width="120" />
<el-table-column prop="author" label="作者" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="views"
label="浏览量"
width="100"
align="center"
/>
<el-table-column
prop="likes"
label="点赞量"
width="100"
align="center"
/>
<el-table-column prop="publish_date" label="发布时间" width="160" />
<el-table-column prop="update_time" label="更新时间" width="160" />
<el-table-column prop="publisher" label="发布人" width="120" />
<el-table-column label="操作" width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.status != 2 && row.status != 0"
size="small"
type=""
@click="handlePulish(row)"
>发布</el-button
>
<el-button
v-if="row.status != 2"
size="small"
type="primary"
@click="handleEdit(row)"
>编辑</el-button
>
<el-button
v-if="row.status != 2"
size="small"
type="danger"
@click="handleDelete(row)"
>删除</el-button
>
<el-button
v-if="row.status === 2"
size="small"
type="danger"
@click="handleUnPulish(row)"
>下架</el-button
>
<el-button
v-if="row.status === 2 && row.recommend === 0"
size="small"
type=""
@click="handleRecommend(row)"
>推荐</el-button
>
<el-button
v-if="row.status === 2 && row.recommend === 1"
size="small"
type="danger"
@click="handleUnRecommend(row)"
>取消推荐</el-button
>
<el-button
v-if="row.status === 2 && row.top === 0"
size="small"
type=""
@click="handleTop(row)"
>置顶</el-button
>
<el-button
v-if="row.status === 2 && row.top === 1"
size="small"
type="danger"
@click="handleUnTop(row)"
>取消置顶</el-button
>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 编辑弹窗 -->
<Edit
v-model="dialogVisible"
:is-edit="isEdit"
:model="currentRow"
@saved="onSaved"
/>
<!-- 预览抽屉 -->
<Preview v-model="previewVisible" :model="currentRow" />
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Search } from "@element-plus/icons-vue";
import Edit from "./components/edit.vue";
import Preview from "./components/preview.vue";
import { useAuthStore } from "@/stores/auth";
import {
listArticles,
deleteArticle,
listCategories,
publishArticle,
unPublishArticle,
getArticle,
articleRecommend,
articleTop,
unArticleRecommend,
unArticleTop,
} from "@/api/article.js";
const loading = ref(false);
const articleList = ref([]);
const searchQuery = ref("");
const categoryFilter = ref("");
const categoryOptions = ref([]);
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);
const dialogVisible = ref(false);
const previewVisible = ref(false);
const isEdit = ref(false);
const currentRow = ref(null);
// 使 auth store
const authStore = useAuthStore();
//
const userInfo = authStore.user;
if (userInfo && userInfo.id) {
// console.log(':', userInfo.account || userInfo.name);
// console.log('ID:', userInfo.id);
// console.log(':', userInfo.role);
} else {
console.log("未找到用户信息或用户未登录");
}
//
function getStatusText(status) {
const statusMap = {
0: "草稿",
1: "待审核",
2: "已发布",
3: "已隐藏",
};
return statusMap[status] || "未知";
}
//
function getStatusType(status) {
const typeMap = {
0: "info",
1: "warning",
2: "success",
3: "danger",
};
return typeMap[status] || "info";
}
//
function getRecommendText(status) {
const statusMap = {
0: "未推荐",
1: "推荐",
};
return statusMap[status] || "未知";
}
//
function getRecommendType(status) {
const typeMap = {
0: "info",
1: "success",
};
return typeMap[status] || "info";
}
//
async function fetchArticleList() {
loading.value = true;
try {
const params = {
keyword: searchQuery.value,
cate: categoryFilter.value,
page: currentPage.value,
pageSize: pageSize.value,
};
const res = await listArticles(params);
if (res.code === 200) {
articleList.value = res.data.list || [];
total.value = res.data.total || 0;
} else {
console.error("获取文章列表失败:", res.msg);
ElMessage.error(res.msg || "获取文章列表失败");
}
} catch (error) {
console.error("请求异常:", error);
ElMessage.error("网络请求失败,请稍后重试");
} finally {
loading.value = false;
}
}
//
function handleFilterChange() {
currentPage.value = 1;
fetchArticleList();
}
//
function handleSizeChange(val) {
pageSize.value = val;
fetchArticleList();
}
//
function handleCurrentChange(val) {
fetchArticleList();
}
function handleAdd() {
isEdit.value = false;
currentRow.value = null;
dialogVisible.value = true;
}
function handleEdit(row) {
isEdit.value = true;
//
getArticle(row.id).then((res) => {
const resp =
res && typeof res.code !== "undefined"
? res
: res && res.data
? res.data
: res;
if (resp && resp.code === 200 && resp.data) {
const m = resp.data;
currentRow.value = {
id: m.id,
title: m.title || "",
author: m.author || "",
cate: m.cate || "",
content: m.content || "",
desc: m.desc || "",
publish_time: m.publish_time || null,
_raw: m,
};
} else {
currentRow.value = { ...row };
}
dialogVisible.value = true;
});
}
function handleView(row) {
//
getArticle(row.id).then((res) => {
const resp =
res && typeof res.code !== "undefined"
? res
: res && res.data
? res.data
: res;
if (resp && resp.code === 200 && resp.data) {
const m = resp.data;
currentRow.value = {
id: m.id,
title: m.title || "",
author: m.author || "",
cate: m.cate || "",
content: m.content || "",
desc: m.desc || "",
view_count: m.view_count || 0,
publisher: m.publisher || "",
create_time: m.create_time || null,
publish_time: m.publish_time || null,
update_time: m.update_time || null,
_raw: m,
};
previewVisible.value = true;
}
});
}
function handleDelete(row) {
ElMessageBox.confirm("确定要删除这篇文章吗?此操作不可恢复。", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
deleteArticle(row.id).then((res) => {
const resp =
res && typeof res.code !== "undefined"
? res
: res && res.data
? res.data
: res;
if (resp && resp.code === 200) {
ElMessage.success("删除成功");
fetchArticleList();
} else {
ElMessage.error((resp && resp.msg) || "删除失败");
}
});
});
}
const uid = userInfo.id;
//
function handlePulish(row) {
ElMessageBox.confirm("确认发布该文章吗?发布后将在前台显示。", "确认发布", {
confirmButtonText: "确认发布",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
publishArticle(row.id, uid)
.then((res) => {
if (res.code === 200) {
ElMessage.success("发布成功");
fetchArticleList();
} else {
ElMessage.error(res.msg || "发布失败");
}
})
.catch((error) => {
console.error("发布失败:", error);
ElMessage.error(error.msg || "发布失败");
});
})
.catch(() => {});
}
//
function handleUnPulish(row) {
ElMessageBox.confirm("确认下架该文章吗?", "确认", {
confirmButtonText: "确认",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
unPublishArticle(row.id)
.then((res) => {
if (res.code === 200) {
ElMessage.success("下架成功");
fetchArticleList();
} else {
ElMessage.error(res.msg || "下架失败");
}
})
.catch((error) => {
console.error("下架失败:", error);
ElMessage.error(error.msg || "下架失败");
});
})
.catch(() => {});
}
//
function handleRecommend(row) {
ElMessageBox.confirm("确认推荐该文章吗?推荐后将在前台显示。", "确认推荐", {
confirmButtonText: "确认推荐",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
articleRecommend(row.id)
.then((res) => {
if (res.code === 200) {
ElMessage.success("推荐成功");
fetchArticleList();
} else {
ElMessage.error(res.msg || "推荐失败");
}
})
.catch((error) => {
console.error("推荐失败:", error);
ElMessage.error(error.msg || "推荐失败");
});
})
.catch(() => {});
}
//
function handleUnRecommend(row) {
ElMessageBox.confirm("确认取消推荐该文章吗?", "确认", {
confirmButtonText: "确认",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
unArticleRecommend(row.id)
.then((res) => {
if (res.code === 200) {
ElMessage.success("取消推荐成功");
fetchArticleList();
} else {
ElMessage.error(res.msg || "取消推荐失败");
}
})
.catch((error) => {
console.error("取消推荐失败:", error);
ElMessage.error(error.msg || "取消推荐失败");
});
})
.catch(() => {});
}
//
function handleTop(row) {
ElMessageBox.confirm("确认置顶该文章吗?置顶后将在前台显示。", "确认置顶", {
confirmButtonText: "确认置顶",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
articleTop(row.id)
.then((res) => {
if (res.code === 200) {
ElMessage.success("置顶成功");
fetchArticleList();
} else {
ElMessage.error(res.msg || "置顶失败");
}
})
.catch((error) => {
console.error("置顶失败:", error);
ElMessage.error(error.msg || "置顶失败");
});
})
.catch(() => {});
}
//
function handleUnTop(row) {
ElMessageBox.confirm("确认取消置顶该文章吗?", "确认", {
confirmButtonText: "确认",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
unArticleTop(row.id)
.then((res) => {
if (res.code === 200) {
ElMessage.success("取消置顶成功");
fetchArticleList();
} else {
ElMessage.error(res.msg || "取消置顶失败");
}
})
.catch((error) => {
console.error("取消置顶失败:", error);
ElMessage.error(error.msg || "取消置顶失败");
});
})
.catch(() => {});
}
function handleSearch() {
currentPage.value = 1;
fetchArticleList();
}
function handleRefresh() {
searchQuery.value = "";
categoryFilter.value = "";
currentPage.value = 1;
fetchArticleList();
}
//
function onSaved() {
dialogVisible.value = false;
fetchArticleList();
ElMessage.success("保存成功");
}
//
const fetchCategories = async () => {
try {
const res = await listCategories({ page: 1, limit: 1000 });
if (res && res.code === 200) {
if (res.data && Array.isArray(res.data.list)) {
categoryOptions.value = res.data.list;
} else if (Array.isArray(res.data)) {
categoryOptions.value = res.data;
} else if (Array.isArray(res.list)) {
categoryOptions.value = res.list;
}
if (categoryOptions.value.length === 0) {
ElMessage.warning("未获取到分类数据");
}
} else {
ElMessage.error(res?.msg || "获取分类列表失败");
}
} catch (error) {
ElMessage.error("获取分类列表失败");
}
};
onMounted(async () => {
await Promise.all([
fetchArticleList(),
fetchCategories(), //
]);
});
</script>
<style lang="less" scoped>
.cms-articles {
padding: 20px;
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
height: 100%;
background: var(--el-bg-color);
.articles-container {
border-radius: 8px;
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 20px;
.search-bar {
margin-left: auto;
width: 300px;
}
}
.filters {
margin-bottom: 20px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
}
</style>
@@ -1,332 +0,0 @@
<template>
<!-- 添加/编辑Banner对话框 -->
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="600px"
:close-on-click-modal="false"
@update:model-value="handleDialogClose"
>
<el-form
:model="currentBanner"
label-width="100px"
:rules="formRules"
ref="bannerFormRef"
>
<el-form-item label="标题" prop="title">
<el-input
v-model="currentBanner.title"
placeholder="请输入Banner标题"
maxlength="255"
show-word-limit
/>
</el-form-item>
<el-form-item label="简介" prop="desc">
<el-input
v-model="currentBanner.desc"
type="textarea"
:rows="4"
placeholder="请输入Banner简介"
/>
</el-form-item>
<el-form-item label="跳转地址" prop="url">
<el-input
v-model="currentBanner.url"
placeholder="例如:https://www.example.com 或 /page/detail"
/>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
支持外部链接http:///
</div>
</el-form-item>
<el-form-item label="Banner图片" prop="image">
<el-upload
class="image-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImageSuccess"
:on-error="handleImageError"
:before-upload="beforeImageUpload"
accept="*"
>
<img
v-if="currentBanner.image"
:src="getImageUrl(currentBanner.image)"
class="image-preview"
/>
<div v-else class="upload-placeholder">
<el-icon class="image-uploader-icon"><Plus /></el-icon>
<div class="el-upload__text">点击上传图片</div>
</div>
</el-upload>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
建议尺寸1920x600支持 jpgpnggif 格式大小不超过 5MB
</div>
<el-button
v-if="currentBanner.image"
size="small"
type="danger"
text
@click="handleRemoveImage"
style="margin-top: 8px;"
>
删除图片
</el-button>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="currentBanner.sort"
:min="0"
placeholder="数字越小越靠前"
style="width: 100%"
/>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
数字越小排序越靠前
</div>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { ElMessage, ElForm } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
// Banner
interface Banner {
id: number;
title: string;
desc: string;
url: string;
image: string;
sort: number;
create_time?: number;
update_time?: number;
delete_time?: number;
}
// Props
interface Props {
visible: boolean;
banner: Partial<Banner> | null;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
banner: null,
});
// Emits
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "save", banner: Partial<Banner>): void;
(e: "cancel"): void;
}>();
//
const bannerFormRef = ref<InstanceType<typeof ElForm>>();
// Banner
const currentBanner = ref<Partial<Banner>>({
id: 0,
title: "",
desc: "",
url: "",
image: "",
sort: 0,
});
//
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const uploadUrl = ref(API_BASE_URL + "/platform/uploadfiles");
const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
});
//
const dialogTitle = computed(() => {
return props.banner?.id && props.banner.id > 0 ? "编辑Banner" : "添加Banner";
});
//
const formRules = ref({
title: [{ required: true, message: "请输入Banner标题", trigger: "blur" }],
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
});
// propsBanner
watch(
() => props.banner,
(newBanner) => {
if (newBanner) {
currentBanner.value = {
...newBanner,
};
} else {
//
currentBanner.value = {
id: 0,
title: "",
desc: "",
url: "",
image: "",
sort: 0,
};
}
},
{ immediate: true }
);
// props.visible
watch(
() => props.visible,
(newVisible) => {
if (newVisible && (!props.banner || !props.banner.id)) {
//
currentBanner.value = {
id: 0,
title: "",
desc: "",
url: "",
image: "",
sort: 0,
};
}
}
);
//
const handleCancel = () => {
emit("update:visible", false);
emit("cancel");
};
//
const handleDialogClose = (value: boolean) => {
if (!value) {
emit("update:visible", false);
emit("cancel");
}
};
//
const beforeImageUpload = (file: any) => {
return true;
};
//
const handleImageSuccess = (response: any) => {
if (response.code === 200) {
// API_BASE_URL
currentBanner.value.image = response.data.url || response.data.path;
ElMessage.success("图片上传成功");
} else {
ElMessage.error(response.msg || "图片上传失败");
}
};
//
const handleImageError = () => {
ElMessage.error("图片上传失败,请重试");
};
//
const handleRemoveImage = () => {
currentBanner.value.image = "";
ElMessage.success("图片已删除");
};
// URL
const getImageUrl = (imagePath: string) => {
if (!imagePath) return "";
//
if (imagePath.startsWith("http")) return imagePath;
// API
return API_BASE_URL + imagePath;
};
// Banner
const handleSave = async () => {
//
if (!bannerFormRef.value) return;
const valid = await bannerFormRef.value.validate();
if (!valid) return;
//
const payload = { ...currentBanner.value };
//
emit("save", payload);
};
</script>
<style lang="less" scoped>
/* 对话框样式精简 */
:deep(.el-dialog__body) {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
}
:deep(.el-form-item) {
margin-bottom: 16px;
}
/* 图片上传样式 */
.image-uploader {
:deep(.el-upload) {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
&:hover {
border-color: var(--el-color-primary);
}
}
}
.image-preview {
width: 100%;
max-width: 500px;
height: auto;
max-height: 300px;
display: block;
object-fit: contain;
border-radius: 4px;
}
.upload-placeholder {
width: 100%;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
.image-uploader-icon {
font-size: 48px;
margin-bottom: 12px;
}
.el-upload__text {
font-size: 14px;
}
}
</style>
@@ -1,301 +0,0 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>Banner管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAddBanner">
<el-icon>
<Plus />
</el-icon>
添加Banner
</el-button>
<el-button @click="refresh" :loading="loading">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 表格 -->
<el-table
:data="bannerList"
style="width: 100%"
border
v-loading="loading"
element-loading-text="正在加载..."
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="title" label="标题" min-width="150" />
<el-table-column label="图片" width="200" align="center">
<template #default="scope">
<el-image
v-if="scope.row.image"
:src="getImageUrl(scope.row.image)"
:preview-src-list="[getImageUrl(scope.row.image)]"
:preview-teleported="true"
fit="cover"
style="width: 100px; height: 60px; border-radius: 4px; cursor: pointer;"
/>
<span v-else style="color: #ccc;">-</span>
</template>
</el-table-column>
<el-table-column prop="desc" label="简介" min-width="200" show-overflow-tooltip>
<template #default="scope">
<span v-if="scope.row.desc">{{ scope.row.desc }}</span>
<span v-else style="color: #ccc;">-</span>
</template>
</el-table-column>
<el-table-column prop="url" label="跳转地址" min-width="200" show-overflow-tooltip>
<template #default="scope">
<el-link v-if="scope.row.url" :href="scope.row.url" target="_blank" type="primary">
{{ scope.row.url }}
</el-link>
<span v-else style="color: #ccc;">-</span>
</template>
</el-table-column>
<el-table-column
prop="sort"
label="排序"
width="100"
align="center"
sortable
>
<template #default="scope">
<el-tag>{{ scope.row.sort || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="scope">
<el-button size="small" text @click="handleEditBanner(scope.row)">
<el-icon>
<Edit />
</el-icon>
<span>编辑</span>
</el-button>
<el-button
size="small"
text
type="danger"
@click="handleDeleteBanner(scope.row)"
>
<el-icon>
<Delete />
</el-icon>
<span>删除</span>
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 引入编辑组件 -->
<BannerEdit
v-model:visible="dialogVisible"
:banner="dialogBanner"
@save="handleBannerSave"
@cancel="handleBannerCancel"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Edit, Delete, Refresh } from "@element-plus/icons-vue";
import {
getBanners,
createBanner,
editBanner,
deleteBanner,
} from "@/api/banner";
import BannerEdit from "./components/edit.vue";
// Banner
interface Banner {
id: number;
title: string;
desc: string;
url: string;
image: string;
sort: number;
create_time?: number;
update_time?: number;
delete_time?: number;
}
// Banner
const bannerList = ref<Banner[]>([]);
const loading = ref(false);
//
const dialogVisible = ref(false);
const dialogBanner = ref<Partial<Banner> | null>(null);
// Banner
const fetchBanners = async () => {
loading.value = true;
try {
const result = await getBanners();
if (result.code === 200) {
bannerList.value = result.data || [];
} else {
ElMessage.error("获取Banner列表失败: " + result.message);
}
} catch (error) {
ElMessage.error("获取Banner列表失败: " + (error as Error).message);
} finally {
loading.value = false;
}
};
//
async function refresh() {
loading.value = true;
try {
await fetchBanners();
ElMessage.success("刷新成功");
} catch (error) {
ElMessage.error("刷新失败");
} finally {
loading.value = false;
}
}
// Banner
const handleAddBanner = () => {
dialogBanner.value = {
id: 0,
title: "",
desc: "",
url: "",
image: "",
sort: 0,
};
dialogVisible.value = true;
};
// Banner
const handleEditBanner = (banner: Banner) => {
dialogBanner.value = { ...banner };
dialogVisible.value = true;
};
// Banner
const handleDeleteBanner = (banner: Banner) => {
ElMessageBox.confirm(
`确定要删除Banner "${banner.title}" 吗?`,
"确认删除",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
).then(async () => {
try {
const result = await deleteBanner(banner.id);
if (result.code === 200) {
ElMessage.success("删除成功");
fetchBanners();
} else {
ElMessage.error("删除失败: " + result.message);
}
} catch (error) {
ElMessage.error("删除失败: " + (error as Error).message);
}
});
};
// Banner
const handleBannerSave = async (banner: Partial<Banner>) => {
try {
const payload = { ...banner };
//
if (!banner.id || banner.id === 0) {
// Banner
const result = await createBanner(payload as Banner);
if (result.code === 200) {
ElMessage.success(result.msg || "Banner添加成功");
dialogVisible.value = false;
await fetchBanners();
} else {
ElMessage.error(result.msg || "添加失败");
}
} else {
// Banner
const result = await editBanner(banner.id!, payload as Banner);
if (result.code === 200) {
ElMessage.success(result.msg || "更新成功");
dialogVisible.value = false;
await fetchBanners();
} else {
ElMessage.error(result.msg || "更新失败");
}
}
} catch (error) {
ElMessage.error("操作失败: " + (error as Error).message);
}
};
// Banner
const handleBannerCancel = () => {
dialogVisible.value = false;
};
// URL
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
const getImageUrl = (imagePath: string) => {
if (!imagePath) return '';
//
if (imagePath.startsWith('http')) return imagePath;
// API
return API_BASE_URL + imagePath;
};
// Banner
onMounted(() => {
fetchBanners();
});
</script>
<style lang="less" scoped>
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
}
.header-bar h2 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.header-actions {
display: flex;
gap: 8px;
}
/* 表格样式 */
:deep(.el-table) {
border-radius: 0;
width: 100% !important;
}
:deep(.el-table__body td) {
padding: 12px 0;
vertical-align: middle;
}
</style>
@@ -1,150 +0,0 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
@close="handleClose"
>
<el-form :model="form" label-width="100px">
<el-form-item label="需求标题" required>
<el-input v-model="form.title" placeholder="请输入需求标题" />
</el-form-item>
<el-form-item label="需求描述" required>
<el-input
v-model="form.desc"
type="textarea"
rows="4"
placeholder="请输入需求描述"
/>
</el-form-item>
<el-form-item label="申请人">
<el-input v-model="form.applicant" placeholder="请输入申请人" />
</el-form-item>
<el-form-item label="电话">
<el-input v-model="form.phone" placeholder="请输入电话" />
</el-form-item>
<el-form-item label="需求状态">
<el-select v-model="form.status" placeholder="请选择状态">
<el-option label="待处理" :value="1" />
<el-option label="处理中" :value="2" />
<el-option label="已完成" :value="3" />
<el-option label="已拒绝" :value="4" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { addDemand, editDemand } from "@/api/demand";
// Props
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "新增需求",
},
demand: {
type: Object,
default: () => ({
id: "",
title: "",
desc: "",
applicant: "",
status: 1,
}),
},
});
// Emits
const emit = defineEmits(["close", "submit"]);
//
const dialogVisible = ref(props.visible);
const dialogTitle = ref(props.title);
//
const form = reactive({
id: "",
title: "",
desc: "",
applicant: "",
status: 1,
});
// props
watch(
() => props.visible,
(newVal) => {
dialogVisible.value = newVal;
},
);
watch(
() => props.title,
(newVal) => {
dialogTitle.value = newVal;
},
);
watch(
() => props.demand,
(newVal) => {
Object.assign(form, newVal);
},
{ deep: true },
);
//
const handleClose = () => {
emit("close");
};
//
const handleSubmit = async () => {
//
if (!form.title || !form.desc) {
ElMessage.warning("请填写必填项");
return;
}
try {
let result;
if (form.id) {
//
result = await editDemand(form.id, form);
} else {
//
result = await addDemand(form);
}
if (result.code === 200) {
ElMessage.success(form.id ? "编辑成功" : "新增成功");
emit("submit", { ...form });
} else {
ElMessage.error(result.msg || "操作失败");
}
} catch (error) {
ElMessage.error("网络错误,请稍后重试");
console.error("提交表单失败:", error);
}
};
</script>
<style lang="less" scoped>
.dialog-footer {
text-align: right;
}
</style>
@@ -1,333 +0,0 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>需求管理</h2>
<div>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增需求
</el-button>
<el-button type="primary" @click="handleRefush">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<!-- 搜索和筛选 -->
<div class="search-bar">
<el-form :inline="true" :model="searchForm" class="mb-4">
<el-form-item label="需求标题">
<el-input
v-model="searchForm.title"
placeholder="请输入需求标题"
style="width: 200px"
/>
</el-form-item>
<el-form-item label="需求状态">
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
style="width: 150px"
>
<el-option label="全部" value="" />
<el-option label="待处理" :value="1" />
<el-option label="处理中" :value="2" />
<el-option label="已完成" :value="3" />
<el-option label="已拒绝" :value="4" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 需求列表 -->
<el-table v-loading="loading" :data="demandList" style="width: 100%" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="100" />
<el-table-column
prop="desc"
label="描述"
min-width="300"
show-overflow-tooltip
/>
<el-table-column prop="applicant" label="申请人" width="120" />
<el-table-column prop="phone" label="电话" width="180" />
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)"
>编辑</el-button
>
<el-button
size="small"
type="danger"
@click="handleDelete(scope.row.id)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination" v-if="total > 0">
<el-pagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 需求表单组件 -->
<Edit
:visible="dialogVisible"
:title="dialogTitle"
:demand="form"
@close="handleFormClose"
@submit="handleFormSubmit"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { Plus, Refresh } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import Edit from "./components/edit.vue";
import { getDemandList, deleteDemand } from "@/api/demand";
//
const loading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref("新增需求");
//
const searchForm = reactive({
title: "",
status: "",
});
//
const pagination = reactive({
current: 1,
size: 10,
});
//
const total = ref(0);
//
const demandList = ref<any[]>([]);
//
const form = reactive({
id: "",
title: "",
desc: "",
applicant: "",
status: 1,
});
//
const getStatusType = (status: string | number) => {
const typeMap: Record<string | number, string> = {
1: "info",
2: "warning",
3: "success",
4: "danger",
};
return typeMap[status] || "info";
};
//
const getStatusText = (status: string | number) => {
const textMap: Record<string | number, string> = {
1: "待处理",
2: "处理中",
3: "已完成",
4: "已拒绝",
};
return textMap[status] || String(status);
};
//
const handleSearch = () => {
pagination.current = 1;
fetchDemandList();
};
//
const resetSearch = () => {
searchForm.title = "";
searchForm.status = "";
pagination.current = 1;
fetchDemandList();
};
//
const fetchDemandList = async () => {
loading.value = true;
try {
const data = await getDemandList();
if (data.code === 200) {
let filteredList = [...data.list];
//
if (searchForm.title) {
filteredList = filteredList.filter((item) =>
item.title.includes(searchForm.title),
);
}
if (searchForm.status) {
filteredList = filteredList.filter(
(item) => item.status === searchForm.status,
);
}
//
total.value = filteredList.length;
const start = (pagination.current - 1) * pagination.size;
const end = start + pagination.size;
demandList.value = filteredList.slice(start, end);
} else {
ElMessage.error("获取需求列表失败");
}
} catch (error) {
ElMessage.error("网络错误,请稍后重试");
console.error("获取需求列表失败:", error);
} finally {
loading.value = false;
}
};
//
const handleSizeChange = (size: number) => {
pagination.size = size;
fetchDemandList();
};
//
const handleCurrentChange = (current: number) => {
pagination.current = current;
fetchDemandList();
};
//
const handleAdd = () => {
dialogTitle.value = "新增需求";
Object.assign(form, {
id: "",
title: "",
desc: "",
applicant: "",
status: 1,
});
dialogVisible.value = true;
};
//
const handleEdit = (row: any) => {
dialogTitle.value = "编辑需求";
Object.assign(form, row);
dialogVisible.value = true;
};
//
const handleDelete = async (id: number) => {
ElMessageBox.confirm("确定要删除这个需求吗?", "删除确认", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
try {
const result = await deleteDemand(id);
if (result.code === 200) {
ElMessage.success("删除成功");
fetchDemandList();
} else {
ElMessage.error(result.msg || "删除失败");
}
} catch (error) {
ElMessage.error("网络错误,请稍后重试");
console.error("删除需求失败:", error);
}
})
.catch(() => {
//
});
};
//
const handleRefush = () => {
fetchDemandList();
};
//
const handleFormClose = () => {
dialogVisible.value = false;
};
//
const handleFormSubmit = () => {
dialogVisible.value = false;
fetchDemandList();
};
//
onMounted(() => {
fetchDemandList();
});
</script>
<style lang="less" scoped>
.container-box {
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.search-bar {
margin-bottom: 20px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.dialog-footer {
text-align: right;
}
</style>
@@ -1,185 +0,0 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>租户域名审核</h2>
<el-button @click="fetchData" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<el-divider></el-divider>
<!-- 搜索区域 -->
<div class="search-box">
<el-input
v-model="searchForm.sub_domain"
placeholder="搜索二级域名"
clearable
style="width: 200px; margin-right: 10px;"
@keyup.enter="handleSearch"
/>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 120px; margin-right: 10px;">
<el-option label="审核中" :value="0" />
<el-option label="已生效" :value="1" />
<el-option label="已禁用" :value="2" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<!-- 表格 -->
<el-table
:data="tableData"
style="width: 100%"
border
v-loading="loading"
element-loading-text="正在加载..."
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="tenant_name" label="租户名称" min-width="150" />
<el-table-column prop="sub_domain" label="二级域名前缀" width="150" />
<el-table-column prop="main_domain" label="主域名" min-width="150" />
<el-table-column prop="full_domain" label="完整域名" min-width="200" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag v-if="scope.row.status === 0" type="warning">审核中</el-tag>
<el-tag v-else-if="scope.row.status === 1" type="success">已生效</el-tag>
<el-tag v-else type="danger">已禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="申请时间" width="180" />
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="scope">
<template v-if="scope.row.status === 0">
<el-button size="small" type="success" @click="handleAudit(scope.row, 'approve')">
通过
</el-button>
<el-button size="small" type="danger" @click="handleAudit(scope.row, 'reject')">
拒绝
</el-button>
</template>
<template v-else-if="scope.row.status === 1">
<el-button size="small" text type="danger" @click="handleDisable(scope.row)">
禁用
</el-button>
</template>
<span v-else style="color: #999;">-</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { getTenantDomainList, auditTenantDomain, toggleTenantDomainStatus } from '@/api/domain'
const loading = ref(false)
const searchForm = reactive({
sub_domain: '',
status: ''
})
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
const tableData = ref<any[]>([])
const fetchData = async () => {
loading.value = true
try {
const res = await getTenantDomainList({
page: pagination.page,
pageSize: pagination.pageSize,
...searchForm
})
if (res.code === 200) {
tableData.value = res.data.list || []
pagination.total = res.data.total || 0
}
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
fetchData()
}
const handleAudit = async (row: any, action: string) => {
const msg = action === 'approve' ? '确定要通过该域名申请吗?' : '确定要拒绝该域名申请吗?'
await ElMessageBox.confirm(msg, '提示', { type: 'warning' })
const res = await auditTenantDomain({ id: row.id, action })
if (res.code === 200) {
ElMessage.success(res.msg)
fetchData()
} else {
ElMessage.error(res.msg || '操作失败')
}
}
const handleDisable = async (row: any) => {
await ElMessageBox.confirm('确定要禁用该域名吗?', '提示', { type: 'warning' })
const res = await toggleTenantDomainStatus(row.id)
if (res.code === 200) {
ElMessage.success(res.msg)
fetchData()
} else {
ElMessage.error(res.msg || '操作失败')
}
}
onMounted(() => {
fetchData()
})
</script>
<style lang="less" scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.search-box {
margin-bottom: 20px;
}
.pagination-wrap {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -1,5 +0,0 @@
<template></template>
<script lang="ts" setup></script>
<style lang="less" scoped></style>
-287
View File
@@ -1,287 +0,0 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>主域名池管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加主域名
</el-button>
<el-button @click="fetchData" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索区域 -->
<div class="search-box">
<el-input
v-model="searchForm.main_domain"
placeholder="搜索主域名"
clearable
style="width: 200px; margin-right: 10px;"
@keyup.enter="handleSearch"
/>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 120px; margin-right: 10px;">
<el-option label="禁用" :value="0" />
<el-option label="启用" :value="1" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<!-- 表格 -->
<el-table
:data="tableData"
style="width: 100%"
border
v-loading="loading"
element-loading-text="正在加载..."
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="main_domain" label="主域名" min-width="200" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
{{ scope.row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" />
<el-table-column label="操作" width="240" fixed="right" align="center">
<template #default="scope">
<el-button size="small" text @click="handleEdit(scope.row)">
<el-icon><Edit /></el-icon>
<span>编辑</span>
</el-button>
<el-button size="small" text @click="handleToggleStatus(scope.row)">
<el-icon><Switch /></el-icon>
<span>{{ scope.row.status === 1 ? '禁用' : '启用' }}</span>
</el-button>
<el-button size="small" text type="danger" @click="handleDelete(scope.row)">
<el-icon><Delete /></el-icon>
<span>删除</span>
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
<!-- 添加/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑主域名' : '添加主域名'"
width="500px"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="主域名" prop="main_domain">
<el-input v-model="form.main_domain" placeholder="请输入主域名,如: example.com" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Refresh, Switch } from '@element-plus/icons-vue'
import {
getDomainPoolList,
createDomainPool,
updateDomainPool,
deleteDomainPool,
toggleDomainPoolStatus
} from '@/api/domain'
const loading = ref(false)
const submitLoading = ref(false)
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref()
//
const searchForm = reactive({
main_domain: '',
status: ''
})
//
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
//
const tableData = ref<any[]>([])
//
const form = reactive({
id: 0,
main_domain: '',
status: 1
})
//
const rules = {
main_domain: [
{ required: true, message: '请输入主域名', trigger: 'blur' }
]
}
//
const fetchData = async () => {
loading.value = true
try {
const res = await getDomainPoolList({
page: pagination.page,
pageSize: pagination.pageSize,
...searchForm
})
if (res.code === 200) {
tableData.value = res.data.list || []
pagination.total = res.data.total || 0
}
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.page = 1
fetchData()
}
//
const handleAdd = () => {
isEdit.value = false
form.id = 0
form.main_domain = ''
form.status = 1
dialogVisible.value = true
}
//
const handleEdit = (row: any) => {
isEdit.value = true
form.id = row.id
form.main_domain = row.main_domain
form.status = row.status
dialogVisible.value = true
}
//
const handleSubmit = async () => {
await formRef.value.validate()
submitLoading.value = true
try {
if (isEdit.value) {
const res = await updateDomainPool(form)
if (res.code === 200) {
ElMessage.success('更新成功')
dialogVisible.value = false
fetchData()
} else {
ElMessage.error(res.msg || '更新失败')
}
} else {
const res = await createDomainPool(form)
if (res.code === 200) {
ElMessage.success('创建成功')
dialogVisible.value = false
fetchData()
} else {
ElMessage.error(res.msg || '创建失败')
}
}
} finally {
submitLoading.value = false
}
}
//
const handleDelete = (row: any) => {
ElMessageBox.confirm('确定要删除该主域名吗?', '提示', {
type: 'warning'
}).then(async () => {
const res = await deleteDomainPool(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchData()
} else {
ElMessage.error(res.msg || '删除失败')
}
})
}
//
const handleToggleStatus = async (row: any) => {
const res = await toggleDomainPoolStatus(row.id)
if (res.code === 200) {
ElMessage.success('状态更新成功')
fetchData()
} else {
ElMessage.error(res.msg || '操作失败')
}
}
onMounted(() => {
fetchData()
})
</script>
<style lang="less" scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 10px;
}
}
.search-box {
margin-bottom: 20px;
}
.pagination-wrap {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -1,302 +0,0 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
destroy-on-close
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="链接名称" prop="link_name">
<el-input v-model="formData.link_name" placeholder="请输入链接名称" />
</el-form-item>
<el-form-item label="链接地址" prop="link_url">
<el-input v-model="formData.link_url" placeholder="请输入链接地址,如:https://www.example.com" />
</el-form-item>
<el-form-item label="Logo" prop="link_logo">
<el-upload
class="image-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImageSuccess"
:on-error="handleImageError"
:before-upload="beforeImageUpload"
accept="image/*"
>
<img
v-if="formData.link_logo"
:src="getImageUrl(formData.link_logo)"
class="image-preview"
/>
<div v-else class="upload-placeholder">
<el-icon class="image-uploader-icon"><Plus /></el-icon>
<div class="el-upload__text">点击上传Logo</div>
</div>
</el-upload>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
建议尺寸200x200支持 jpgpnggif 格式大小不超过 2MB
</div>
<el-button
v-if="formData.link_logo"
size="small"
type="danger"
text
@click="handleRemoveImage"
style="margin-top: 8px;"
>
删除图片
</el-button>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入描述"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { addFriendlink, updateFriendlink } from '@/api/friendlink'
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: '添加友情链接'
},
isEdit: {
type: Boolean,
default: false
},
rowData: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['update:modelValue', 'success'])
const visible = ref(false)
const submitLoading = ref(false)
const formRef = ref(null)
//
const uploadUrl = ref(API_BASE_URL + "/platform/uploadfiles");
const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
});
//
const beforeImageUpload = (file) => {
const isImage = file.type.startsWith('image/');
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
ElMessage.error('请上传图片文件!');
return false;
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB!');
return false;
}
return true;
};
//
const handleImageSuccess = (response) => {
if (response.code === 200 || response.code === 201) {
formData.link_logo = response.data.url || response.data.path;
ElMessage.success(response.code === 201 ? "文件已存在,直接使用" : "图片上传成功");
} else {
ElMessage.error(response.msg || "图片上传失败");
}
};
//
const handleImageError = () => {
ElMessage.error("图片上传失败,请重试");
};
//
const handleRemoveImage = () => {
formData.link_logo = "";
ElMessage.success("图片已删除");
};
// URL
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (imagePath.startsWith("http")) return imagePath;
return API_BASE_URL + imagePath;
};
const formData = reactive({
link_name: '',
link_url: '',
link_logo: '',
description: '',
sort: 0,
status: 1
})
const formRules = {
link_name: [
{ required: true, message: '请输入链接名称', trigger: 'blur' },
{ max: 100, message: '长度不超过100个字符', trigger: 'blur' }
],
link_url: [
{ required: true, message: '请输入链接地址', trigger: 'blur' },
{ type: 'url', message: '请输入正确的URL地址', trigger: 'blur' }
]
}
// modelValue
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
resetForm()
if (props.isEdit && props.rowData) {
Object.assign(formData, props.rowData)
}
}
})
// visible
watch(visible, (val) => {
emit('update:modelValue', val)
})
//
const resetForm = () => {
formData.link_name = ''
formData.link_url = ''
formData.link_logo = ''
formData.description = ''
formData.sort = 0
formData.status = 1
}
//
const handleClose = () => {
visible.value = false
}
//
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true
try {
let res
if (props.isEdit) {
res = await updateFriendlink(props.rowData.id, formData)
} else {
res = await addFriendlink(formData)
}
if (res.code === 200) {
ElMessage.success(props.isEdit ? '更新成功' : '添加成功')
handleClose()
emit('success')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
}
})
}
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 图片上传样式 */
.image-uploader {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: 120px;
height: 120px;
}
.image-uploader:hover {
border-color: var(--el-color-primary);
}
.upload-placeholder {
width: 120px;
height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
}
.image-uploader-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.el-upload__text {
font-size: 12px;
}
.image-preview {
width: 120px;
height: 120px;
object-fit: contain;
display: block;
}
</style>
@@ -1,324 +0,0 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>友情链接管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加链接
</el-button>
<el-button @click="refresh" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchForm.keyword"
placeholder="请输入链接名称搜索"
clearable
style="width: 200px; margin-right: 10px"
@keyup.enter="handleSearch"
/>
<el-select
v-model="searchForm.status"
placeholder="状态筛选"
clearable
style="width: 120px; margin-right: 10px"
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table
:data="friendlinkList"
style="width: 100%"
v-loading="loading"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="link_name" label="链接名称" min-width="150" align="center" />
<el-table-column prop="link_url" label="链接地址" min-width="200" align="center">
<template #default="{ row }">
<el-link :href="row.link_url" target="_blank" type="primary">
{{ row.link_url }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="link_logo" label="Logo" width="100" align="center">
<template #default="{ row }">
<el-image
v-if="row.link_logo"
:src="getImageUrl(row.link_logo)"
style="width: 50px; height: 50px; object-fit: contain"
preview-teleported
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="150" align="center" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="(val) => handleStatusChange(row, val)"
/>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 添加/编辑组件 -->
<EditDialog
v-model="dialogVisible"
:title="dialogTitle"
:is-edit="isEdit"
:row-data="currentRow"
@success="handleSuccess"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
import {
getFriendlinkList,
updateFriendlink,
deleteFriendlink
} from '@/api/friendlink'
import EditDialog from './components/edit.vue'
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
// URL
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (imagePath.startsWith("http")) return imagePath;
return API_BASE_URL + imagePath;
};
//
const loading = ref(false)
//
const searchForm = reactive({
keyword: '',
status: ''
})
//
const pagination = reactive({
page: 1,
limit: 10,
total: 0
})
//
const friendlinkList = ref([])
const selectedIds = ref([])
//
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const currentRow = ref({})
//
const fetchList = async () => {
loading.value = true
try {
const res = await getFriendlinkList({
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword,
status: searchForm.status
})
if (res.code === 200) {
friendlinkList.value = res.data.list
pagination.total = res.data.total
}
} catch (error) {
console.error('获取友情链接列表失败:', error)
ElMessage.error('获取列表失败')
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.page = 1
fetchList()
}
//
const resetSearch = () => {
searchForm.keyword = ''
searchForm.status = ''
pagination.page = 1
fetchList()
}
//
const refresh = () => {
fetchList()
}
//
const handleSizeChange = (val) => {
pagination.limit = val
fetchList()
}
const handlePageChange = (val) => {
pagination.page = val
fetchList()
}
//
const handleSelectionChange = (selection) => {
selectedIds.value = selection.map(item => item.id)
}
//
const handleAdd = () => {
isEdit.value = false
dialogTitle.value = '添加友情链接'
currentRow.value = {}
dialogVisible.value = true
}
//
const handleEdit = (row) => {
isEdit.value = true
dialogTitle.value = '编辑友情链接'
currentRow.value = row
dialogVisible.value = true
}
//
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该友情链接吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteFriendlink(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
}
}
//
const handleStatusChange = async (row, val) => {
try {
const res = await updateFriendlink(row.id, { status: val })
if (res.code === 200) {
ElMessage.success('状态更新成功')
} else {
ElMessage.error(res.msg || '状态更新失败')
row.status = val === 1 ? 0 : 1
}
} catch (error) {
console.error('状态更新失败:', error)
ElMessage.error('状态更新失败')
row.status = val === 1 ? 0 : 1
}
}
//
const handleSuccess = () => {
fetchList()
}
//
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.search-bar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -1,514 +0,0 @@
<template>
<!-- 添加/编辑菜单对话框 -->
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="500px"
:close-on-click-modal="false"
@update:model-value="handleDialogClose"
>
<el-form
:model="currentMenu"
label-width="100px"
:rules="formRules"
ref="menuFormRef"
>
<el-form-item label="父级菜单" prop="pid">
<el-tree-select
v-model="currentMenu.pid"
:data="parentMenuOptions"
:props="{ value: 'id', label: 'title', children: 'children' }"
placeholder="请选择父级菜单"
clearable
check-strictly
:render-after-expand="false"
style="width: 100%"
@change="handleParentChange"
/>
</el-form-item>
<el-form-item label="菜单名称" prop="title">
<el-input v-model="currentMenu.title" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单类型" prop="type">
<el-radio-group v-model="currentMenu.type" style="width: 100%">
<el-radio-button :value="1">目录</el-radio-button>
<el-radio-button :value="2">页面</el-radio-button>
<el-radio-button :value="3">外链</el-radio-button>
<el-radio-button :value="4">单页</el-radio-button>
</el-radio-group>
<div
style="
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
"
>
<div>
目录只有路由地址用于<span
style="color: var(--el-color-primary)"
>目录管理</span
><span style="color: var(--el-color-primary)">菜单分组</span>
</div>
<div>
页面有路由和组件地址用于<span
style="color: var(--el-color-primary)"
>页面管理</span
>
</div>
<div>
外链无路由和组件用于<span
style="color: var(--el-color-primary)"
>外链管理</span
><span style="color: var(--el-color-primary)">权限控制</span>
</div>
<div>
单页根据路由从单页表获取内容显示无需填写组件路径
</div>
</div>
</el-form-item>
<el-form-item label="路由地址" prop="path" v-if="currentMenu.type !== 3">
<el-input v-model="currentMenu.path" placeholder="例如:/system" />
<div v-if="currentMenu.type === 4" style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
单页类型路由需与单页管理中的路由一致系统会自动从单页表获取内容
</div>
</el-form-item>
<el-form-item
label="组件路径"
prop="component_path"
v-if="currentMenu.type === 2"
>
<el-input
v-model="currentMenu.component_path"
placeholder="例如:/apps/knowledge/index.vue"
/>
</el-form-item>
<el-form-item
label="外链地址"
prop="link_url"
v-if="currentMenu.type === 3"
>
<el-input
v-model="currentMenu.link_url"
required
placeholder="例如:https://www.baidu.com"
/>
</el-form-item>
<el-form-item label="菜单图片" prop="image">
<el-upload
class="image-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImageSuccess"
:on-error="handleImageError"
:before-upload="beforeImageUpload"
accept="image/*"
>
<img v-if="currentMenu.image" :src="getImageUrl(currentMenu.image)" class="image-preview" />
<div v-else class="upload-placeholder">
<el-icon class="image-uploader-icon"><Plus /></el-icon>
<div class="el-upload__text">点击上传图片</div>
</div>
</el-upload>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
建议尺寸400x300支持 jpgpnggif 格式大小不超过 2MB
</div>
<el-button
v-if="currentMenu.image"
size="small"
type="danger"
text
@click="handleRemoveImage"
style="margin-top: 8px;"
>
删除图片
</el-button>
</el-form-item>
<el-form-item label="描述" prop="desc">
<el-input
v-model="currentMenu.desc"
type="textarea"
:rows="3"
placeholder="例如:系统管理"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="currentMenu.sort"
:min="0"
placeholder="数字越小越靠前"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { ElMessage, ElForm } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
//
interface Menu {
id: number;
pid: number;
title: string;
type: number;
path: string;
component_path: string;
link_url?: string;
image?: string;
sort: number;
desc: string;
children?: Menu[];
hasChildren?: boolean;
}
// Props
interface Props {
visible: boolean;
menu: Partial<Menu> | null;
parentMenuOptions: Menu[];
dialogType: "add" | "edit" | "addSub";
parentTitle?: string;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
menu: null,
parentMenuOptions: () => [],
dialogType: "add",
parentTitle: "",
});
// Emits
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "save", menu: Partial<Menu>): void;
(e: "cancel"): void;
}>();
//
const menuFormRef = ref<InstanceType<typeof ElForm>>();
//
const currentMenu = ref<Partial<Menu>>({
id: 0,
pid: 0,
title: "",
type: 1,
path: "",
component_path: "",
image: "",
sort: 0,
desc: "",
});
//
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const uploadUrl = ref(API_BASE_URL + '/platform/uploadfiles');
const uploadHeaders = ref({
'Authorization': 'Bearer ' + (localStorage.getItem('token') || '')
});
//
const findMenuPath = (menuList: Menu[], targetId: number): string => {
for (const menu of menuList) {
if (menu.id === targetId) {
return menu.path || "";
}
if (menu.children && menu.children.length > 0) {
const childPath = findMenuPath(menu.children, targetId);
if (childPath) return childPath;
}
}
return "";
};
// -
const handleParentChange = (value: number) => {
if (value === 0) {
//
currentMenu.value.path = "";
} else {
//
const parentPath = findMenuPath(props.parentMenuOptions, value);
if (parentPath) {
currentMenu.value.path = parentPath;
}
}
};
// props
watch(
() => props.menu,
(newMenu) => {
if (newMenu) {
currentMenu.value = {
...newMenu,
// pid
pid: newMenu.pid ?? 0,
};
} else {
//
currentMenu.value = {
id: 0,
pid: 0,
title: "",
path: "",
component_path: "",
sort: 0,
desc: "",
type: 1,
};
}
},
{ immediate: true }
);
// props.visible
watch(
() => props.visible,
(newVisible) => {
if (newVisible && props.dialogType === "add") {
//
currentMenu.value = {
id: 0,
pid: 0,
title: "",
path: "",
component_path: "",
image: "",
sort: 0,
desc: "",
type: 1,
};
}
}
);
//
const dialogTitle = computed(() => {
switch (props.dialogType) {
case "add":
return "添加菜单";
case "edit":
return "编辑菜单";
case "addSub":
return `添加子菜单 (父菜单: ${props.parentTitle || "顶级菜单"})`;
default:
return "操作菜单";
}
});
//
const formRules = ref({
title: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
path: [
{
required: true,
validator: (rule: any, value: any, callback: any) => {
if (currentMenu.value.type === 3) {
//
callback();
} else if (!value || value.trim() === "") {
callback(new Error("请输入路由地址"));
} else {
callback();
}
},
trigger: "blur",
},
],
component_path: [
{
required: true,
validator: (rule: any, value: any, callback: any) => {
if (currentMenu.value.type === 2) {
//
if (!value || value.trim() === "") {
callback(new Error("请输入组件路径"));
} else {
callback();
}
} else {
//
callback();
}
},
trigger: "blur",
},
],
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
});
//
watch(
() => currentMenu.value.type,
(newType, oldType) => {
if (newType === oldType) return; //
if (newType === 1) {
//
currentMenu.value.component_path = "";
} else if (newType === 2) {
//
//
} else if (newType === 3) {
//
currentMenu.value.path = "";
currentMenu.value.component_path = "";
} else if (newType === 4) {
//
currentMenu.value.component_path = "";
}
}
);
//
const handleCancel = () => {
emit("update:visible", false);
emit("cancel");
};
//
const handleDialogClose = (value: boolean) => {
if (!value) {
emit("update:visible", false);
emit("cancel");
}
};
//
const beforeImageUpload = (file: any) => {
const isImage = file.type.startsWith('image/');
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
ElMessage.error('只能上传图片文件!');
return false;
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB!');
return false;
}
return true;
};
//
const handleImageSuccess = (response: any) => {
if (response.code === 200) {
// API_BASE_URL
currentMenu.value.image = response.data.url || response.data.path;
ElMessage.success('图片上传成功');
} else {
ElMessage.error(response.msg || '图片上传失败');
}
};
//
const handleImageError = () => {
ElMessage.error('图片上传失败,请重试');
};
//
const handleRemoveImage = () => {
currentMenu.value.image = '';
ElMessage.success('图片已删除');
};
// URL
const getImageUrl = (imagePath: string) => {
if (!imagePath) return '';
//
if (imagePath.startsWith('http')) return imagePath;
// API
return API_BASE_URL + imagePath;
};
//
const handleSave = async () => {
//
if (!menuFormRef.value) return;
const valid = await menuFormRef.value.validate();
if (!valid) return;
//
const payload = { ...currentMenu.value };
//
emit("save", payload);
};
</script>
<style lang="less" scoped>
/* 对话框样式精简 */
:deep(.el-dialog__body) {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
}
:deep(.el-form-item) {
margin-bottom: 16px;
}
/* 图片上传样式 */
.image-uploader {
:deep(.el-upload) {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
&:hover {
border-color: var(--el-color-primary);
}
}
}
.image-preview {
width: 178px;
height: 178px;
display: block;
object-fit: cover;
}
.upload-placeholder {
width: 178px;
height: 178px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
.image-uploader-icon {
font-size: 28px;
margin-bottom: 8px;
}
.el-upload__text {
font-size: 14px;
}
}
</style>
@@ -1,563 +0,0 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>前端导航管理</h2>
<div class="header-actions">
<el-button @click="expandAll">
<el-icon>
<FolderOpened />
</el-icon>
全部展开
</el-button>
<el-button @click="collapseAll">
<el-icon>
<Folder />
</el-icon>
全部折叠
</el-button>
<el-button type="primary" @click="handleAddMenu">
<el-icon>
<Plus />
</el-icon>
添加菜单
</el-button>
<el-button @click="refresh" :loading="loading">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 树形表格 -->
<el-table
ref="tableRef"
:data="menuTree"
style="width: 100%"
row-key="id"
border
v-loading="loading"
element-loading-text="正在加载..."
:tree-props="{
children: 'children',
hasChildren: 'hasChildren',
}"
@row-click="handleRowClick"
>
<el-table-column prop="title" label="菜单名称" width="200">
<template #default="scope">
<div class="menu-item">
<i
v-if="scope.row.icon"
:class="scope.row.icon"
class="menu-icon"
></i>
<span>{{ scope.row.title }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="path" label="路由地址"></el-table-column>
<el-table-column label="图片" width="200" align="center">
<template #default="scope">
<el-image
v-if="scope.row.image"
:src="getImageUrl(scope.row.image)"
:preview-src-list="[getImageUrl(scope.row.image)]"
:preview-teleported="true"
fit="cover"
style="width: 50px; height: 50px; border-radius: 4px;"
/>
<span v-else style="color: #ccc;">-</span>
</template>
</el-table-column>
<el-table-column
prop="MenuType"
label="菜单类型"
width="120"
align="center"
>
<template #default="scope">
<el-tag :type="getMenuTypeTagType(scope.row.type)">
{{ getMenuTypeTitle(scope.row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="sort"
label="排序"
width="80"
align="center"
></el-table-column>
<el-table-column label="操作" width="280" fixed="right" align="center">
<template #default="scope">
<el-button
size="small"
text
@click="handleAddSubMenu(scope.row)"
:disabled="scope.row.type === 3"
>
<el-icon>
<CirclePlus />
</el-icon>
<span>子菜单</span>
</el-button>
<el-button size="small" text @click="handleEditMenu(scope.row)">
<el-icon>
<Edit />
</el-icon>
<span>编辑</span>
</el-button>
<el-button
size="small"
text
type="danger"
@click="handleDeleteMenu(scope.row)"
>
<el-icon>
<Delete />
</el-icon>
<span>删除</span>
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 引入编辑组件 -->
<MenuEdit
v-model:visible="dialogVisible"
:menu="dialogMenu"
:parent-menu-options="parentMenuOptions"
:dialog-type="dialogType"
:parent-title="dialogParentTitle"
@save="handleMenuSave"
@cancel="handleMenuCancel"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { ElMessage, ElMessageBox, ElForm } from "element-plus";
import {
Plus,
CirclePlus,
Edit,
Delete,
Refresh,
FolderOpened,
Folder,
} from "@element-plus/icons-vue";
import {
getFrontMenus,
createFrontMenu,
editFrontMenu,
deleteFrontMenu,
} from "@/api/frontMenu";
import MenuEdit from "./components/edit.vue";
//
interface Menu {
id: number;
pid: number;
title: string;
type: number;
path: string;
component_path: string;
image?: string;
sort: number;
desc: string;
children?: Menu[];
hasChildren?: boolean;
}
//
const menuTree = ref<Menu[]>([]);
const loading = ref(false);
//
const tableRef = ref<any>(null);
//
const dialogVisible = ref(false);
const dialogMenu = ref<Partial<Menu> | null>(null);
const dialogType = ref<"add" | "edit" | "addSub">("add");
const dialogParentTitle = ref("");
//
const parentMenuOptions = ref<Menu[]>([]);
let fetchMenusPromise: Promise<any> | null = null;
const fetchMenus = async () => {
if (fetchMenusPromise) {
return fetchMenusPromise;
}
loading.value = true;
fetchMenusPromise = (async () => {
try {
const result = await getFrontMenus();
if (result.code === 200) {
menuTree.value = result.data;
parentMenuOptions.value = [
{
id: 0,
pid: -1,
title: "顶级菜单",
type: 1,
path: "",
component_path: "",
sort: 0,
desc: "",
children: result.data,
} as Menu,
];
} else {
ElMessage.error("获取前端导航失败: " + result.msg);
}
} catch (error) {
ElMessage.error("获取前端导航数据失败: " + (error as Error).message);
} finally {
loading.value = false;
fetchMenusPromise = null;
}
})();
return fetchMenusPromise;
};
//
async function refresh() {
loading.value = true;
try {
await fetchMenus();
ElMessage.success("刷新成功");
} catch (error) {
ElMessage.error("刷新失败");
} finally {
loading.value = false;
}
}
//
function getAllMenuRows(menuList: Menu[]): Menu[] {
const rows: Menu[] = [];
menuList.forEach((frontMenu) => {
rows.push(frontMenu);
if (frontMenu.children && frontMenu.children.length > 0) {
rows.push(...getAllMenuRows(frontMenu.children));
}
});
return rows;
}
//
function expandAll() {
if (!tableRef.value) return;
const allRows = getAllMenuRows(menuTree.value);
allRows.forEach((row) => {
if (row.children && row.children.length > 0) {
tableRef.value.toggleRowExpansion(row, true);
}
});
}
//
function collapseAll() {
if (!tableRef.value) return;
const allRows = getAllMenuRows(menuTree.value);
allRows.forEach((row) => {
if (row.children && row.children.length > 0) {
tableRef.value.toggleRowExpansion(row, false);
}
});
}
// - /
function handleRowClick(row: Menu, column: any, event: Event) {
// /
if (column && column.label === '操作') {
return;
}
// /
if (row.children && row.children.length > 0) {
if (tableRef.value) {
tableRef.value.toggleRowExpansion(row);
}
}
}
//
const buildMenuTree = (menuList: Menu[]): Menu[] => {
return menuList;
};
//
const getMenuTypeTitle = (type: number) => {
const typeMap = { 1: "目录", 2: "页面", 3: "外链", 4: "单页" };
return typeMap[type as keyof typeof typeMap] || "未知类型";
};
//
const getMenuTypeTagType = (type: number) => {
const typeMap = { 1: "primary", 2: "success", 3: "info", 4: "warning" };
return typeMap[type as keyof typeof typeMap] || "default";
};
//
const handleAddSubMenu = (parentMenu: Menu) => {
dialogType.value = "addSub";
dialogParentTitle.value = parentMenu.title;
dialogMenu.value = {
id: 0, // 0
pid: parentMenu.id,
title: "",
path: parentMenu.path || "", //
component_path: "",
desc: "",
sort: 0,
type: parentMenu.type === 1 ? 2 : parentMenu.type, //
};
dialogVisible.value = true;
};
//
const handleEditMenu = (menu: Menu) => {
dialogType.value = "edit";
dialogMenu.value = { ...menu };
dialogVisible.value = true;
};
//
const handleDeleteMenu = (menu: Menu) => {
ElMessageBox.confirm(
`确定要删除菜单 "${menu.title}" 吗?${
menu.hasChildren ? "其下所有子菜单也将被删除。" : ""
}`,
"确认删除",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
).then(async () => {
try {
const result = await deleteFrontMenu(menu.id);
if (result.code === 200) {
ElMessage.success("删除成功");
fetchMenus();
} else {
ElMessage.error("删除失败: " + result.msg);
}
} catch (error) {
ElMessage.error("删除失败: " + (error as Error).message);
}
});
};
//
const handleAddMenu = () => {
dialogType.value = "add";
dialogMenu.value = {
id: 0,
pid: 0,
title: "",
type: 1,
path: "",
component_path: "",
sort: 0,
desc: "",
};
dialogVisible.value = true;
};
//
const handleMenuSave = async (menu: Partial<Menu>) => {
try {
//
const payload = { ...menu };
// pid
// pid
let pidValue: any = payload.pid;
if (Array.isArray(pidValue)) {
pidValue = pidValue.length > 0 ? pidValue[0] : null;
}
//
if (pidValue === null || pidValue === undefined || pidValue === '') {
payload.pid = 0;
} else {
const parsedPid = parseInt(String(pidValue), 10);
if (isNaN(parsedPid)) {
payload.pid = 0;
} else {
payload.pid = parsedPid;
}
}
// id id 0 dialogType add/addSub
if (!menu.id || menu.id === 0 || dialogType.value === 'add' || dialogType.value === 'addSub') {
//
const result = await createFrontMenu(payload as Menu);
if (result.code === 200) {
ElMessage.success(result.msg || "菜单添加成功");
dialogVisible.value = false;
await fetchMenus();
} else {
ElMessage.error(result.msg || "添加失败");
}
} else {
//
const result = await editFrontMenu(menu.id!, payload as Menu);
if (result.code === 200) {
ElMessage.success(result.msg || "更新成功");
dialogVisible.value = false;
await fetchMenus();
} else {
ElMessage.error(result.msg || "更新失败");
}
}
} catch (error) {
ElMessage.error("操作失败: " + (error as Error).message);
}
};
//
const handleMenuCancel = () => {
dialogVisible.value = false;
};
// URL
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
const getImageUrl = (imagePath: string) => {
if (!imagePath) return '';
//
if (imagePath.startsWith('http')) return imagePath;
// API
return API_BASE_URL + imagePath;
};
//
onMounted(() => {
fetchMenus();
});
</script>
<style lang="less" scoped>
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
}
.header-bar h2 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.header-actions {
display: flex;
gap: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f2f3f5;
}
.card-header span {
font-size: 18px;
font-weight: 500;
}
/* 表格核心样式 */
:deep(.el-table) {
border-radius: 0;
width: 100% !important;
}
:deep(.el-table__body td) {
padding: 12px 0;
vertical-align: middle;
}
/* 有子菜单的行显示手型光标 */
:deep(.el-table__body tr.el-table__row--level-0) {
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-light);
}
}
:deep(.el-table__body tr) {
&:has(.el-table__expand-icon:not(.el-table__expand-icon--hidden)) {
cursor: pointer;
}
}
/* 展开图标与菜单内容对齐 */
:deep(.el-table__expand-icon) {
margin: 0 !important;
vertical-align: middle;
}
:deep(.el-table__expand-icon-cell) {
padding: 0 8px !important;
}
/* 菜单项样式 */
.menu-item {
display: inline-flex;
align-items: center;
gap: 8px;
vertical-align: middle;
}
.menu-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
/* 隐藏无子女菜单的展开图标 */
:deep(.el-table__expand-icon--hidden) {
visibility: hidden;
width: 24px;
}
:deep(.el-table__expand-icon) {
margin-right: 8px !important;
vertical-align: middle;
}
/* 对话框样式精简 */
:deep(.el-dialog__body) {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
}
:deep(.el-form-item) {
margin-bottom: 16px;
}
</style>
-7
View File
@@ -1,7 +0,0 @@
<template>
<router-view />
</template>
<script lang="ts" setup></script>
<style lang="less" scoped></style>
@@ -1,245 +0,0 @@
<template>
<!-- 添加/编辑单页对话框 -->
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="80%"
:close-on-click-modal="false"
@update:model-value="handleDialogClose"
>
<el-form
:model="currentOnePage"
label-width="100px"
:rules="formRules"
ref="onePageFormRef"
>
<el-form-item label="标题" prop="title">
<el-input
v-model="currentOnePage.title"
placeholder="请输入单页标题"
maxlength="255"
show-word-limit
/>
</el-form-item>
<el-form-item label="路由" prop="path">
<el-input
v-model="currentOnePage.path"
placeholder="例如:/about、/contact、/privacy"
maxlength="200"
/>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
路由地址必须以 / 开头例如/about
</div>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model="currentOnePage.content"
type="textarea"
:rows="20"
placeholder="请输入单页内容(支持代码)"
style="font-family: 'Courier New', monospace;"
/>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
支持代码和文本内容使用等宽字体显示
</div>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="currentOnePage.sort"
:min="0"
placeholder="数字越小越靠前"
style="width: 100%"
/>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
数字越小排序越靠前
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-switch
v-model="currentOnePage.status"
:active-value="1"
:inactive-value="0"
/>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
启用后前端可以访问该单页
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { ElMessage, ElForm } from "element-plus";
//
interface OnePage {
id: number;
title: string;
content: string;
path: string;
sort: number;
status: 0 | 1;
create_time?: string;
update_time?: string;
delete_time?: string;
}
// Props
interface Props {
visible: boolean;
onepage: Partial<OnePage> | null;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
onepage: null,
});
// Emits
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "save", onepage: Partial<OnePage>): void;
(e: "cancel"): void;
}>();
//
const onePageFormRef = ref<InstanceType<typeof ElForm>>();
//
const currentOnePage = ref<Partial<OnePage>>({
id: 0,
title: "",
content: "",
path: "",
sort: 0,
status: 1,
});
//
const dialogTitle = computed(() => {
return props.onepage?.id && props.onepage.id > 0 ? "编辑单页" : "添加单页";
});
//
const formRules = ref({
title: [{ required: true, message: "请输入单页标题", trigger: "blur" }],
path: [
{ required: true, message: "请输入路由", trigger: "blur" },
{
pattern: /^\/[a-zA-Z0-9\/_-]*$/,
message: "路由必须以 / 开头,只能包含字母、数字、下划线、横线和斜线",
trigger: "blur",
},
],
content: [{ required: true, message: "请输入单页内容", trigger: "blur" }],
sort: [{ required: true, message: "请输入排序号", trigger: "blur" }],
});
// props
watch(
() => props.onepage,
(newOnePage) => {
if (newOnePage) {
currentOnePage.value = {
...newOnePage,
};
} else {
//
currentOnePage.value = {
id: 0,
title: "",
content: "",
path: "",
sort: 0,
status: 1,
};
}
},
{ immediate: true }
);
// props.visible
watch(
() => props.visible,
(newVisible) => {
if (newVisible && (!props.onepage || !props.onepage.id)) {
//
currentOnePage.value = {
id: 0,
title: "",
content: "",
path: "",
sort: 0,
status: 1,
};
}
}
);
//
const handleCancel = () => {
emit("update:visible", false);
emit("cancel");
};
//
const handleDialogClose = (value: boolean) => {
if (!value) {
emit("update:visible", false);
emit("cancel");
}
};
//
const handleSave = async () => {
//
if (!onePageFormRef.value) return;
const valid = await onePageFormRef.value.validate();
if (!valid) return;
//
const payload = { ...currentOnePage.value };
//
emit("save", payload);
};
</script>
<style lang="less" scoped>
/* 对话框样式精简 */
:deep(.el-dialog__body) {
padding: 20px;
max-height: 80vh;
overflow-y: auto;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
/* 代码编辑器样式 */
:deep(.el-textarea__inner) {
font-family: 'Courier New', 'Consolas', 'Monaco', monospace;
font-size: 14px;
line-height: 1.6;
resize: vertical;
}
</style>
@@ -1,298 +0,0 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>单页管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAddOnePage">
<el-icon>
<Plus />
</el-icon>
添加单页
</el-button>
<el-button @click="refresh" :loading="loading">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 表格 -->
<el-table
:data="onePageList"
style="width: 100%"
border
v-loading="loading"
element-loading-text="正在加载..."
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="title" label="标题" min-width="150" />
<el-table-column prop="path" label="路由" width="200">
<template #default="scope">
<el-tag type="info">{{ scope.row.path }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip>
<template #default="scope">
<div style="font-family: 'Courier New', monospace; white-space: pre-wrap;">{{ getContentPreview(scope.row.content) }}</div>
</template>
</el-table-column>
<el-table-column
prop="status"
label="状态"
width="100"
align="center"
>
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">
{{ scope.row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="sort"
label="排序"
width="100"
align="center"
sortable
>
<template #default="scope">
<el-tag>{{ scope.row.sort || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="scope">
<el-button size="small" text @click="handleEditOnePage(scope.row)">
<el-icon>
<Edit />
</el-icon>
<span>编辑</span>
</el-button>
<el-button
size="small"
text
type="danger"
@click="handleDeleteOnePage(scope.row)"
>
<el-icon>
<Delete />
</el-icon>
<span>删除</span>
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 引入编辑组件 -->
<OnePageEdit
v-model:visible="dialogVisible"
:onepage="dialogOnePage"
@save="handleOnePageSave"
@cancel="handleOnePageCancel"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Edit, Delete, Refresh } from "@element-plus/icons-vue";
import {
getOnePages,
createOnePage,
editOnePage,
deleteOnePage,
} from "@/api/onepage";
import OnePageEdit from "./components/edit.vue";
//
interface OnePage {
id: number;
title: string;
content: string;
path: string;
sort: number;
status: 0 | 1;
create_time?: string;
update_time?: string;
delete_time?: string;
}
//
const onePageList = ref<OnePage[]>([]);
const loading = ref(false);
//
const dialogVisible = ref(false);
const dialogOnePage = ref<Partial<OnePage> | null>(null);
//
const fetchOnePages = async () => {
loading.value = true;
try {
const result = await getOnePages();
if (result.code === 200) {
onePageList.value = result.data || [];
} else {
ElMessage.error("获取单页列表失败: " + result.message);
}
} catch (error) {
ElMessage.error("获取单页列表失败: " + (error as Error).message);
} finally {
loading.value = false;
}
};
//
async function refresh() {
loading.value = true;
try {
await fetchOnePages();
ElMessage.success("刷新成功");
} catch (error) {
ElMessage.error("刷新失败");
} finally {
loading.value = false;
}
}
//
const getContentPreview = (content: string) => {
if (!content) return '-';
//
return content.length > 100 ? content.substring(0, 100) + '...' : content;
};
//
const handleAddOnePage = () => {
dialogOnePage.value = {
id: 0,
title: "",
content: "",
path: "",
sort: 0,
status: 1,
};
dialogVisible.value = true;
};
//
const handleEditOnePage = (onePage: OnePage) => {
dialogOnePage.value = { ...onePage };
dialogVisible.value = true;
};
//
const handleDeleteOnePage = (onePage: OnePage) => {
ElMessageBox.confirm(
`确定要删除单页 "${onePage.title}" 吗?`,
"确认删除",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
).then(async () => {
try {
const result = await deleteOnePage(onePage.id);
if (result.code === 200) {
ElMessage.success("删除成功");
fetchOnePages();
} else {
ElMessage.error("删除失败: " + result.message);
}
} catch (error) {
ElMessage.error("删除失败: " + (error as Error).message);
}
});
};
//
const handleOnePageSave = async (onePage: Partial<OnePage>) => {
try {
const payload = { ...onePage };
//
if (!onePage.id || onePage.id === 0) {
//
const result = await createOnePage(payload as OnePage);
if (result.code === 200) {
ElMessage.success(result.msg || "单页添加成功");
dialogVisible.value = false;
await fetchOnePages();
} else {
ElMessage.error(result.msg || "添加失败");
}
} else {
//
const result = await editOnePage(onePage.id!, payload as OnePage);
if (result.code === 200) {
ElMessage.success(result.msg || "更新成功");
dialogVisible.value = false;
await fetchOnePages();
} else {
ElMessage.error(result.msg || "更新失败");
}
}
} catch (error) {
ElMessage.error("操作失败: " + (error as Error).message);
}
};
//
const handleOnePageCancel = () => {
dialogVisible.value = false;
};
//
onMounted(() => {
fetchOnePages();
});
</script>
<style lang="less" scoped>
.container-box {
padding: 24px;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
}
.header-bar h2 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.header-actions {
display: flex;
gap: 8px;
}
/* 表格样式 */
:deep(.el-table) {
border-radius: 0;
width: 100% !important;
}
:deep(.el-table__body td) {
padding: 12px 0;
vertical-align: middle;
}
</style>
@@ -1,321 +0,0 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
destroy-on-close
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="产品名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入产品名称" />
</el-form-item>
<el-form-item label="产品描述" prop="desc">
<el-input
v-model="formData.desc"
type="textarea"
:rows="5"
placeholder="请输入产品描述"
/>
</el-form-item>
<el-form-item label="产品内容" prop="content">
<div class="editor-container">
<WangEditor v-model="formData.content" style="height: 450px;" />
</div>
</el-form-item>
<el-form-item label="产品图片" prop="thumb">
<div class="flex-direction">
<el-upload
class="image-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImageSuccess"
:on-error="handleImageError"
:before-upload="beforeImageUpload"
accept="image/*"
>
<img
v-if="formData.thumb"
:src="getImageUrl(formData.thumb)"
class="image-preview"
/>
<div v-else class="upload-placeholder">
<el-icon class="image-uploader-icon"><Plus /></el-icon>
<div class="el-upload__text">点击上传产品图片</div>
</div>
</el-upload>
<div
style="
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
"
>
建议尺寸200x200支持 jpgpnggif 格式大小不超过 2MB
</div>
</div>
<el-button
v-if="formData.thumb"
size="small"
type="danger"
text
@click="handleRemoveImage"
style="margin-top: 8px"
>
删除产品图片
</el-button>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { addProducts, updateProducts } from "@/api/products";
import WangEditor from "@/views/components/WangEditor.vue";
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "添加产品",
},
isEdit: {
type: Boolean,
default: false,
},
rowData: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(["update:modelValue", "success"]);
const visible = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
//
const uploadUrl = ref(API_BASE_URL + "/platform/uploadfiles");
const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
});
//
const beforeImageUpload = (file) => {
const isImage = file.type.startsWith("image/");
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
ElMessage.error("请上传图片文件!");
return false;
}
if (!isLt2M) {
ElMessage.error("图片大小不能超过 2MB!");
return false;
}
return true;
};
//
const handleImageSuccess = (response) => {
if (response.code === 200 || response.code === 201) {
formData.thumb = response.data.url || response.data.path;
ElMessage.success(
response.code === 201 ? "文件已存在,直接使用" : "图片上传成功",
);
} else {
ElMessage.error(response.msg || "图片上传失败");
}
};
//
const handleImageError = () => {
ElMessage.error("图片上传失败,请重试");
};
//
const handleRemoveImage = () => {
formData.thumb = "";
ElMessage.success("图片已删除");
};
// URL
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (imagePath.startsWith("http")) return imagePath;
return API_BASE_URL + imagePath;
};
const formData = reactive({
title: "",
desc: "",
thumb: "",
url: "",
sort: 0,
status: 1,
});
const formRules = {
title: [
{ required: true, message: "请输入产品名称", trigger: "blur" },
{ max: 100, message: "长度不超过100个字符", trigger: "blur" },
],
desc: [
{ required: true, message: "请输入产品描述", trigger: "blur" },
{ max: 200, message: "描述长度不超过200个字符", trigger: "blur" },
],
};
// modelValue
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
resetForm();
if (props.isEdit && props.rowData) {
Object.assign(formData, props.rowData);
}
}
},
);
// visible
watch(visible, (val) => {
emit("update:modelValue", val);
});
//
const resetForm = () => {
formData.title = "";
formData.desc = "";
formData.thumb = "";
formData.url = "";
formData.sort = 0;
formData.status = 1;
};
//
const handleClose = () => {
visible.value = false;
};
//
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
let res;
if (props.isEdit) {
res = await updateProducts(props.rowData.id, formData);
} else {
res = await addProducts(formData);
}
if (res.code === 200) {
ElMessage.success(props.isEdit ? "更新成功" : "添加成功");
handleClose();
emit("success");
} else {
ElMessage.error(res.msg || "操作失败");
}
} catch (error) {
console.error("提交失败:", error);
ElMessage.error("操作失败");
} finally {
submitLoading.value = false;
}
}
});
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 图片上传样式 */
.image-uploader {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: 120px;
height: 120px;
}
.image-uploader:hover {
border-color: var(--el-color-primary);
}
.upload-placeholder {
width: 120px;
height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
}
.image-uploader-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.el-upload__text {
font-size: 12px;
}
.image-preview {
width: 120px;
height: 120px;
object-fit: contain;
display: block;
}
.flex-direction {
display: flex;
flex-direction: column;
align-items: flex-start;
}
</style>
@@ -1,295 +0,0 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>企业产品管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleTypes">
<i class="fa-solid fa-layer-group"></i>
分类管理
</el-button>
<el-button type="primary" @click="handleAdd">
<i class="fa-solid fa-plus"></i>
添加产品
</el-button>
<el-button @click="refresh" :loading="loading">
<i class="fa-solid fa-refresh"></i>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="searchForm.keyword" placeholder="请输入产品名称搜索" clearable style="width: 200px; margin-right: 10px"
@keyup.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态筛选" clearable style="width: 120px; margin-right: 10px">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
<el-button type="primary" @click="handleSearch">
<el-icon>
<Search />
</el-icon>
搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="productsList" style="width: 100%" v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="thumb" label="产品图片" min-width="150" align="center">
<template #default="{ row }">
<el-image v-if="row.thumb" :src="getImageUrl(row.thumb)" :preview-src-list="[getImageUrl(row.thumb)]"
fit="cover" class="product-thumb" :preview-teleported="true" lazy>
<template #error>
<div class="image-slot">
<el-icon>
<Picture />
</el-icon>
</div>
</template>
</el-image>
<span v-else class="text-gray">暂无图片</span>
</template>
</el-table-column>
<el-table-column prop="title" label="产品名称" min-width="150" align="center" />
<el-table-column prop="desc" label="产品描述" min-width="150" align="center" />
<el-table-column prop="url" label="跳转地址" min-width="150" align="center" />
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
<el-icon>
<Edit />
</el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit"
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handlePageChange" />
</div>
<!-- 添加/编辑组件 -->
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
@success="handleSuccess" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import {
getProductsList,
updateProducts,
deleteProducts
} from '@/api/products'
import EditDialog from './components/edit.vue'
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const router = useRouter()
// URL
//
// 1) http(s)://...
// 2) / /storage/uploads/xxx.png
// 3) / storage/uploads/xxx.png
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (typeof imagePath !== 'string') imagePath = String(imagePath);
if (imagePath.startsWith("http")) return imagePath;
const base = (API_BASE_URL ?? '').replace(/\/$/, '');
const path = imagePath.startsWith('/') ? imagePath : `/${imagePath}`;
return base + path;
};
//
const loading = ref(false)
//
const searchForm = reactive({
keyword: '',
status: ''
})
//
const pagination = reactive({
page: 1,
limit: 10,
total: 0
})
//
const productsList = ref([])
const selectedIds = ref([])
//
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const currentRow = ref({})
//
const fetchList = async () => {
loading.value = true
try {
const res = await getProductsList({
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword,
status: searchForm.status
})
if (res.code === 200) {
productsList.value = res.data.list
pagination.total = res.data.total
}
} catch (error) {
console.error('获取企业产品列表失败:', error)
ElMessage.error('获取企业产品列表失败')
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.page = 1
fetchList()
}
//
const resetSearch = () => {
searchForm.keyword = ''
searchForm.status = ''
pagination.page = 1
fetchList()
}
//
const refresh = () => {
fetchList()
}
//
const handleSizeChange = (val) => {
pagination.limit = val
fetchList()
}
const handlePageChange = (val) => {
pagination.page = val
fetchList()
}
//
const handleSelectionChange = (selection) => {
selectedIds.value = selection.map(item => item.id)
}
//
const handleAdd = () => {
isEdit.value = false
dialogTitle.value = '添加企业产品'
currentRow.value = {}
dialogVisible.value = true
}
//
const handleEdit = (row) => {
isEdit.value = true
dialogTitle.value = '编辑企业产品'
currentRow.value = row
dialogVisible.value = true
}
//
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该企业产品吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteProducts(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除企业产品失败:', error)
ElMessage.error('删除企业产品失败')
}
}
}
//
const handleSuccess = () => {
fetchList()
}
//
const handleTypes = () => {
router.push('/apps/cms/products/types')
}
//
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.search-bar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -1,211 +0,0 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
destroy-on-close
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="分类名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="父级分类" prop="pid">
<el-select
v-model="formData.pid"
placeholder="请选择父级分类"
clearable
style="width: 100%"
>
<el-option label="顶级分类" :value="0" />
<el-option
v-for="item in typeOptions"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="分类描述" prop="desc">
<el-input
v-model="formData.desc"
type="textarea"
:rows="4"
placeholder="请输入分类描述(可选)"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
addProductsTypes,
updateProductsTypes,
getProductsTypesList
} from '@/api/products'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: '添加分类'
},
isEdit: {
type: Boolean,
default: false
},
rowData: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['update:modelValue', 'success'])
const visible = ref(false)
const submitLoading = ref(false)
const formRef = ref(null)
const typeOptions = ref([])
const formData = reactive({
title: '',
pid: 0,
desc: '',
sort: 0
})
const formRules = {
title: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ max: 100, message: '长度不超过100个字符', trigger: 'blur' }
]
}
const resetForm = () => {
formData.title = ''
formData.pid = 0
formData.desc = ''
formData.sort = 0
}
const loadTypeOptions = async () => {
try {
const res = await getProductsTypesList({ page: 1, limit: 1000, keyword: '' })
if (res.code === 200 && Array.isArray(res.data?.list)) {
const curId = props.isEdit ? Number(props.rowData?.id ?? 0) : 0
typeOptions.value = curId
? res.data.list.filter((item) => Number(item.id) !== curId)
: res.data.list
} else {
typeOptions.value = []
}
} catch (e) {
//
console.error('加载父级分类失败:', e)
typeOptions.value = []
}
}
// /
watch(
() => props.modelValue,
async (val) => {
visible.value = val
if (val) {
resetForm()
if (props.isEdit && props.rowData) {
Object.assign(formData, props.rowData)
formData.pid = Number(props.rowData?.pid ?? 0)
formData.sort = Number(props.rowData?.sort ?? 0)
}
await loadTypeOptions()
}
}
)
watch(visible, (val) => {
emit('update:modelValue', val)
})
const handleClose = () => {
visible.value = false
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
let res
const payload = {
title: formData.title,
pid: Number(formData.pid ?? 0),
desc: formData.desc ?? '',
sort: Number(formData.sort ?? 0)
}
if (props.isEdit) {
res = await updateProductsTypes(Number(props.rowData?.id), payload)
} else {
res = await addProductsTypes(payload)
}
if (res.code === 200) {
ElMessage.success(props.isEdit ? '更新分类成功' : '添加分类成功')
handleClose()
emit('success')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
console.error('提交分类失败:', e)
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
})
}
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -1,240 +0,0 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>企业产品分类管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<i class="fa-solid fa-plus"></i>
添加分类
</el-button>
<el-button @click="refresh" :loading="loading">
<i class="fa-solid fa-refresh"></i>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchForm.keyword"
placeholder="请输入分类名称搜索"
clearable
style="width: 200px; margin-right: 10px"
@keyup.enter="handleSearch" />
<el-button type="primary" @click="handleSearch">
<el-icon>
<Search />
</el-icon>
搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="typesList" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="title" label="分类名称" min-width="180" align="center" />
<el-table-column prop="desc" label="分类描述" min-width="200" align="center" />
<el-table-column prop="pid" label="父级ID" width="110" align="center" />
<el-table-column prop="sort" label="排序" width="90" align="center" />
<el-table-column prop="create_time" label="创建时间" min-width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
<el-icon>
<Edit />
</el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit"
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handlePageChange" />
</div>
<!-- 添加/编辑组件 -->
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
@success="handleSuccess" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
import {
getProductsTypesList,
deleteProductsTypes
} from '@/api/products'
import EditDialog from './components/edit.vue'
//
const loading = ref(false)
//
const searchForm = reactive({
keyword: ''
})
//
const pagination = reactive({
page: 1,
limit: 10,
total: 0
})
//
const typesList = ref([])
//
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const currentRow = ref({})
//
const fetchList = async () => {
loading.value = true
try {
const res = await getProductsTypesList({
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword
})
if (res.code === 200) {
typesList.value = res.data.list
pagination.total = res.data.total
}
} catch (error) {
console.error('获取企业产品分类列表失败:', error)
ElMessage.error('获取企业产品分类列表失败')
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.page = 1
fetchList()
}
//
const resetSearch = () => {
searchForm.keyword = ''
pagination.page = 1
fetchList()
}
//
const refresh = () => {
fetchList()
}
//
const handleSizeChange = (val) => {
pagination.limit = val
fetchList()
}
const handlePageChange = (val) => {
pagination.page = val
fetchList()
}
//
const handleAdd = () => {
isEdit.value = false
dialogTitle.value = '添加企业产品分类'
currentRow.value = {}
dialogVisible.value = true
}
//
const handleEdit = (row) => {
isEdit.value = true
dialogTitle.value = '编辑企业产品分类'
currentRow.value = row
dialogVisible.value = true
}
//
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该企业产品分类吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteProductsTypes(row.id)
if (res.code === 200) {
ElMessage.success('删除分类成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除企业产品分类失败:', error)
ElMessage.error('删除企业产品分类失败')
}
}
}
//
const handleSuccess = () => {
fetchList()
}
//
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.search-bar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -1,3 +0,0 @@
<template>这是cate</template>
<script lang="ts" setup></script>
<style lang="less" scoped></style>
@@ -1,3 +0,0 @@
<template>这是list</template>
<script lang="ts" setup></script>
<style lang="less" scoped></style>
@@ -1,315 +0,0 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
destroy-on-close
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="服务名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入服务名称" />
</el-form-item>
<el-form-item label="服务描述" prop="desc">
<el-input
v-model="formData.desc"
type="textarea"
:rows="5"
placeholder="请输入服务描述"
/>
</el-form-item>
<el-form-item label="Logo" prop="thumb">
<div class="flex-direction">
<el-upload
class="image-uploader"
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImageSuccess"
:on-error="handleImageError"
:before-upload="beforeImageUpload"
accept="image/*"
>
<img
v-if="formData.thumb"
:src="getImageUrl(formData.thumb)"
class="image-preview"
/>
<div v-else class="upload-placeholder">
<el-icon class="image-uploader-icon"><Plus /></el-icon>
<div class="el-upload__text">点击上传服务图标</div>
</div>
</el-upload>
<div
style="
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
"
>
建议尺寸200x200支持 jpgpnggif 格式大小不超过 2MB
</div>
</div>
<el-button
v-if="formData.thumb"
size="small"
type="danger"
text
@click="handleRemoveImage"
style="margin-top: 8px"
>
删除图标
</el-button>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { addService, updateService } from "@/api/services";
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "添加服务",
},
isEdit: {
type: Boolean,
default: false,
},
rowData: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(["update:modelValue", "success"]);
const visible = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
//
const uploadUrl = ref(API_BASE_URL + "/platform/uploadfiles");
const uploadHeaders = ref({
Authorization: "Bearer " + (localStorage.getItem("token") || ""),
});
//
const beforeImageUpload = (file) => {
const isImage = file.type.startsWith("image/");
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isImage) {
ElMessage.error("请上传图片文件!");
return false;
}
if (!isLt2M) {
ElMessage.error("图片大小不能超过 2MB!");
return false;
}
return true;
};
//
const handleImageSuccess = (response) => {
if (response.code === 200 || response.code === 201) {
formData.link_logo = response.data.url || response.data.path;
ElMessage.success(
response.code === 201 ? "文件已存在,直接使用" : "图片上传成功",
);
} else {
ElMessage.error(response.msg || "图片上传失败");
}
};
//
const handleImageError = () => {
ElMessage.error("图片上传失败,请重试");
};
//
const handleRemoveImage = () => {
formData.link_logo = "";
ElMessage.success("图片已删除");
};
// URL
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (imagePath.startsWith("http")) return imagePath;
return API_BASE_URL + imagePath;
};
const formData = reactive({
title: "",
desc: "",
thumb: "",
url: "",
sort: 0,
status: 1,
});
const formRules = {
title: [
{ required: true, message: "请输入服务名称", trigger: "blur" },
{ max: 100, message: "长度不超过100个字符", trigger: "blur" },
],
desc: [
{ required: true, message: "请输入服务描述", trigger: "blur" },
{ max: 200, message: "描述长度不超过200个字符", trigger: "blur" },
],
};
// modelValue
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
resetForm();
if (props.isEdit && props.rowData) {
Object.assign(formData, props.rowData);
}
}
},
);
// visible
watch(visible, (val) => {
emit("update:modelValue", val);
});
//
const resetForm = () => {
formData.title = "";
formData.desc = "";
formData.thumb = "";
formData.url = "";
formData.sort = 0;
formData.status = 1;
};
//
const handleClose = () => {
visible.value = false;
};
//
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
let res;
if (props.isEdit) {
res = await updateService(props.rowData.id, formData);
} else {
res = await addService(formData);
}
if (res.code === 200) {
ElMessage.success(props.isEdit ? "更新成功" : "添加成功");
handleClose();
emit("success");
} else {
ElMessage.error(res.msg || "操作失败");
}
} catch (error) {
console.error("提交失败:", error);
ElMessage.error("操作失败");
} finally {
submitLoading.value = false;
}
}
});
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 图片上传样式 */
.image-uploader {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: 120px;
height: 120px;
}
.image-uploader:hover {
border-color: var(--el-color-primary);
}
.upload-placeholder {
width: 120px;
height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
}
.image-uploader-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.el-upload__text {
font-size: 12px;
}
.image-preview {
width: 120px;
height: 120px;
object-fit: contain;
display: block;
}
.flex-direction {
display: flex;
flex-direction: column;
align-items: flex-start;
}
</style>
@@ -1,281 +0,0 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>特色服务管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<el-icon>
<Plus />
</el-icon>
添加内容
</el-button>
<el-button @click="refresh" :loading="loading">
<el-icon>
<Refresh />
</el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="searchForm.keyword" placeholder="请输入内容搜索" clearable style="width: 200px; margin-right: 10px"
@keyup.enter="handleSearch" />
<el-select v-model="searchForm.status" placeholder="状态筛选" clearable style="width: 120px; margin-right: 10px">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
<el-button type="primary" @click="handleSearch">
<el-icon>
<Search />
</el-icon>
搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="servicesList" style="width: 100%" v-loading="loading" @selection-change="handleSelectionChange">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="thumb" label="服务图片" min-width="150" align="center" />
<el-table-column prop="title" label="服务名称" min-width="150" align="center" />
<el-table-column prop="desc" label="服务描述" min-width="150" align="center" />
<el-table-column prop="url" label="跳转地址" min-width="150" align="center" />
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="create_time" label="创建时间" width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
<el-icon>
<Edit />
</el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit"
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handlePageChange" />
</div>
<!-- 添加/编辑组件 -->
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
@success="handleSuccess" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
import {
getServiceList,
updateService,
deleteService
} from '@/api/services'
import EditDialog from './components/edit.vue'
// @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
// URL
const getImageUrl = (imagePath) => {
if (!imagePath) return "";
if (imagePath.startsWith("http")) return imagePath;
return API_BASE_URL + imagePath;
};
//
const loading = ref(false)
//
const searchForm = reactive({
keyword: '',
status: ''
})
//
const pagination = reactive({
page: 1,
limit: 10,
total: 0
})
//
const servicesList = ref([])
const selectedIds = ref([])
//
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const currentRow = ref({})
//
const fetchList = async () => {
loading.value = true
try {
const res = await getServiceList({
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword,
status: searchForm.status
})
if (res.code === 200) {
servicesList.value = res.data.list
pagination.total = res.data.total
}
} catch (error) {
console.error('获取特色服务列表失败:', error)
ElMessage.error('获取特色服务列表失败')
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.page = 1
fetchList()
}
//
const resetSearch = () => {
searchForm.keyword = ''
searchForm.status = ''
pagination.page = 1
fetchList()
}
//
const refresh = () => {
fetchList()
}
//
const handleSizeChange = (val) => {
pagination.limit = val
fetchList()
}
const handlePageChange = (val) => {
pagination.page = val
fetchList()
}
//
const handleSelectionChange = (selection) => {
selectedIds.value = selection.map(item => item.id)
}
//
const handleAdd = () => {
isEdit.value = false
dialogTitle.value = '添加特色服务'
currentRow.value = {}
dialogVisible.value = true
}
//
const handleEdit = (row) => {
isEdit.value = true
dialogTitle.value = '编辑特色服务'
currentRow.value = row
dialogVisible.value = true
}
//
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该特色服务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteService(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除特色服务失败:', error)
ElMessage.error('删除特色服务失败')
}
}
}
//
const handleStatusChange = async (row, val) => {
try {
const res = await updateService(row.id, { status: val })
if (res.code === 200) {
ElMessage.success('状态更新成功')
} else {
ElMessage.error(res.msg || '状态更新失败')
row.status = val === 1 ? 0 : 1
}
} catch (error) {
console.error('更新特色服务状态失败:', error)
ElMessage.error('更新特色服务状态失败')
row.status = val === 1 ? 0 : 1
}
}
//
const handleSuccess = () => {
fetchList()
}
//
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.search-bar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -1,281 +0,0 @@
<template>
<div class="template-layout">
<aside class="category-sidebar">
<div class="sidebar-title">模板管理</div>
<ul class="category-list">
<li class="current-theme">
<span>当前使用:</span>
<el-tag type="success">{{ currentTheme }}</el-tag>
</li>
</ul>
</aside>
<main class="content-area">
<header class="toolbar">
<div class="toolbar-left">
<span class="result-count"> {{ templateList.length }} 个模板</span>
</div>
<div class="toolbar-right">
<el-button type="primary" @click="fetchTemplates" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新模板
</el-button>
</div>
</header>
<el-divider></el-divider>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载模板中...</span>
</div>
<!-- 模板列表 -->
<div v-else class="template-grid">
<div v-for="item in templateList" :key="item.key" class="template-card" :class="{ active: item.key === currentTheme }">
<div class="card-preview">
<img :src="getPreviewUrl(item.preview)" alt="preview" @error="handleImageError($event)" />
<div v-if="item.key === currentTheme" class="current-tag">
<el-tag type="success" size="small">使用中</el-tag>
</div>
</div>
<div class="card-info">
<h4 class="title">{{ item.name }}</h4>
<p class="description">{{ item.description }}</p>
<div class="meta">
<span class="version">v{{ item.version }}</span>
<span v-if="item.author" class="author">{{ item.author }}</span>
</div>
<div class="card-footer">
<el-button
v-if="item.key !== currentTheme"
type="primary"
size="small"
@click="handleUse(item)"
:loading="switching === item.key"
>
启用
</el-button>
<el-button v-else type="info" size="small" disabled>
当前使用
</el-button>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, Loading } from '@element-plus/icons-vue'
import { getThemeList, switchTheme } from '@/api/theme'
import { useAuthStore } from '@/stores/auth'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
const authStore = useAuthStore()
//
const loading = ref(false)
const switching = ref('')
const templateList = ref<any[]>([])
const currentTheme = ref('default')
const currentTid = ref<number>(0)
// URL
const getPreviewUrl = (path: string) => {
if (!path) return ''
if (path.startsWith('http')) return path
return API_BASE_URL + path
}
//
const fetchTemplates = async () => {
loading.value = true
try {
currentTid.value = (authStore.user as any)?.tid || 0
const res = await getThemeList({ tid: currentTid.value })
if (res.code === 200) {
templateList.value = res.data.list || []
currentTheme.value = res.data.currentTheme || 'default'
} else {
ElMessage.error(res.msg || '获取模板列表失败')
}
} catch (error) {
ElMessage.error('获取模板列表失败')
} finally {
loading.value = false
}
}
//
const handleUse = async (item: any) => {
if (!currentTid.value) {
ElMessage.error('请先选择租户')
return
}
switching.value = item.key
try {
const res = await switchTheme({ tid: currentTid.value, theme_key: item.key })
if (res.code === 200) {
currentTheme.value = item.key
ElMessage.success('切换成功')
} else {
ElMessage.error(res.msg || '切换失败')
}
} catch (error) {
ElMessage.error('切换失败')
} finally {
switching.value = ''
}
}
//
const handleImageError = (event: Event) => {
const img = event.target as HTMLImageElement
img.src = 'https://picsum.photos/300/200?random=1'
}
//
onMounted(() => {
fetchTemplates()
})
</script>
<style lang="less" scoped>
.template-layout {
display: flex;
height: 100vh;
background-color: #f0f2f5;
.category-sidebar {
width: 240px;
background: #fff;
border-right: 1px solid #e8e8e8;
padding: 20px 0;
.sidebar-title {
padding: 0 24px 16px;
font-size: 16px;
font-weight: bold;
color: #333;
}
.current-theme {
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
}
}
.content-area {
flex: 1;
padding: 0 24px 24px;
overflow-y: auto;
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
height: 64px;
.result-count {
color: #666;
font-size: 14px;
}
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
padding: 60px;
color: #999;
gap: 10px;
.el-icon {
font-size: 24px;
}
}
.template-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.template-card {
background: #fff;
border-radius: 8px;
overflow: hidden;
border: 2px solid #f0f0f0;
transition: all 0.3s;
&.active {
border-color: #1890ff;
}
&:hover {
box-shadow: 0 6px 16px rgba(0,0,0,0.12);
}
.card-preview {
height: 200px;
position: relative;
overflow: hidden;
background: #f5f5f5;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.current-tag {
position: absolute;
top: 8px;
right: 8px;
}
}
.card-info {
padding: 16px;
.title {
margin: 0 0 8px 0;
font-size: 16px;
color: #333;
}
.description {
font-size: 12px;
color: #999;
margin: 0 0 12px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #999;
margin-bottom: 12px;
}
.card-footer {
display: flex;
gap: 8px;
}
}
}
}
}
</style>
@@ -1,278 +0,0 @@
<template>
<div class="exam-workbench">
<!-- 数据统计 -->
<div class="statistics-section">
<el-row :gutter="20">
<el-col :span="6">
<div class="stat-card">
<div class="stat-icon">
<i class="fa-solid fa-file-lines"></i>
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.examCount }}</div>
<div class="stat-label">考试总数</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="stat-icon">
<i class="fa-solid fa-pen"></i>
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.practiceCount }}</div>
<div class="stat-label">练习总数</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="stat-icon">
<i class="fa-solid fa-book"></i>
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.courseCount }}</div>
<div class="stat-label">课程总数</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="stat-icon">
<i class="fa-solid fa-users"></i>
</div>
<div class="stat-content">
<div class="stat-value">{{ statistics.studentCount }}</div>
<div class="stat-label">考生总数</div>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- 快捷功能 -->
<div class="quick-actions">
<h3 class="section-title">快捷功能</h3>
<el-row :gutter="20">
<el-col :span="4">
<div class="action-card" @click="handleCreateExam">
<div class="action-icon">
<i class="fa-solid fa-file-circle-plus"></i>
</div>
<div class="action-label">创建考试</div>
</div>
</el-col>
<el-col :span="4">
<div class="action-card" @click="handleCreatePractice">
<div class="action-icon">
<i class="fa-solid fa-pen-to-square"></i>
</div>
<div class="action-label">创建练习</div>
</div>
</el-col>
<el-col :span="4">
<div class="action-card" @click="handleCreateCourse">
<div class="action-icon">
<i class="fa-solid fa-book-open"></i>
</div>
<div class="action-label">创建课程</div>
</div>
</el-col>
<el-col :span="4">
<div class="action-card" @click="handleBatchImport">
<div class="action-icon">
<i class="fa-solid fa-file-import"></i>
</div>
<div class="action-label">批量导题</div>
</div>
</el-col>
<el-col :span="4">
<div class="action-card" @click="handleStudentManage">
<div class="action-icon">
<i class="fa-solid fa-user-group"></i>
</div>
<div class="action-label">考生管理</div>
</div>
</el-col>
<el-col :span="4">
<div class="action-card" @click="handleQuestionBank">
<div class="action-icon">
<i class="fa-solid fa-database"></i>
</div>
<div class="action-label">试题库</div>
</div>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
const router = useRouter();
const statistics = ref({
examCount: 0,
practiceCount: 0,
courseCount: 0,
studentCount: 0
});
const handleCreateExam = () => {
router.push('/apps/exams/exam');
};
const handleCreatePractice = () => {
ElMessage.info('创建练习功能开发中');
};
const handleCreateCourse = () => {
ElMessage.info('创建课程功能开发中');
};
const handleBatchImport = () => {
ElMessage.info('批量导题功能开发中');
};
const handleStudentManage = () => {
ElMessage.info('考生管理功能开发中');
};
const handleQuestionBank = () => {
ElMessage.info('试题库功能开发中');
};
const fetchStatistics = async () => {
try {
// TODO: API
statistics.value = {
examCount: 0,
practiceCount: 0,
courseCount: 0,
studentCount: 0
};
} catch (error) {
console.error('获取统计数据失败', error);
}
};
onMounted(() => {
fetchStatistics();
});
</script>
<style lang="less" scoped>
.exam-workbench {
padding: 20px;
.statistics-section {
margin-bottom: 30px;
.stat-card {
background: #fff;
border-radius: 8px;
padding: 24px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.3s;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
i {
font-size: 28px;
color: #fff;
}
}
.stat-content {
flex: 1;
.stat-value {
font-size: 28px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #909399;
}
}
}
}
.quick-actions {
.section-title {
font-size: 18px;
font-weight: 600;
color: #303133;
margin-bottom: 20px;
}
.action-card {
background: #fff;
border-radius: 8px;
padding: 32px 20px;
text-align: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.3s;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
.action-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
i {
color: #fff;
}
}
}
.action-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
transition: all 0.3s;
i {
font-size: 32px;
color: #667eea;
transition: all 0.3s;
}
}
.action-label {
font-size: 14px;
font-weight: 500;
color: #606266;
}
}
}
}
</style>
-11
View File
@@ -1,11 +0,0 @@
<script setup>
// Apps
</script>
<template>
<router-view />
</template>
<style scoped>
/* Apps 父路由容器 */
</style>
@@ -0,0 +1,304 @@
<template>
<div class="wang-editor-wrapper" :class="{ focused: isFocused }">
<div ref="toolbarRef" class="toolbar-container"></div>
<div ref="editorRef" class="editor-container"></div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import { ElMessage } from "element-plus";
import "@wangeditor/editor/dist/css/style.css";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
});
const emit = defineEmits(['update:modelValue']);
const toolbarRef = ref(null);
const editorRef = ref(null);
const isFocused = ref(false);
let editorInstance = null;
let isDestroyed = false;
//
const handleUploadImage = async (file, insertFn) => {
try {
//
// const formData = new FormData();
// formData.append('file', file);
// const response = await uploadFile(formData);
//
const reader = new FileReader();
reader.onload = (e) => {
insertFn(e.target.result, file.name);
ElMessage.success('图片上传成功');
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Upload error:', error);
ElMessage.error('上传失败:' + (error.message || '未知错误'));
}
};
//
const initEditor = async () => {
if (!editorRef.value || !toolbarRef.value || isDestroyed) return;
try {
const { createEditor, createToolbar } = await import("@wangeditor/editor");
const editorConfig = {
placeholder: "请输入内容...",
onChange: (editor) => {
if (!isDestroyed) {
const html = editor.getHtml();
emit("update:modelValue", html);
}
},
MENU_CONF: {
uploadImage: {
customUpload: async (file, insertFn) => {
await handleUploadImage(file, insertFn);
},
allowedFileTypes: ["image/*"],
maxFileSize: 5 * 1024 * 1024, // 5MB
},
},
};
editorInstance = createEditor({
selector: editorRef.value,
html: props.modelValue || "",
config: editorConfig,
mode: "default",
});
createToolbar({
editor: editorInstance,
selector: toolbarRef.value,
config: {},
});
nextTick(() => {
if (editorInstance) {
const editorDom = editorRef.value;
if (editorDom) {
const textDom = editorDom.querySelector(".w-e-text");
if (textDom) {
textDom.addEventListener("focus", () => {
isFocused.value = true;
});
textDom.addEventListener("blur", () => {
isFocused.value = false;
});
}
}
}
});
} catch (error) {
console.error("Failed to initialize editor:", error);
}
};
watch(
() => props.modelValue,
(newVal) => {
if (editorInstance && newVal !== editorInstance.getHtml()) {
editorInstance.setHtml(newVal || "");
}
}
);
defineExpose({
clear: () => {
if (editorInstance) {
editorInstance.clear();
}
},
getContent: () => {
if (editorInstance) {
return editorInstance.getHtml();
}
return props.modelValue;
},
setContent: (content) => {
if (editorInstance) {
editorInstance.setHtml(content || "");
}
},
});
onMounted(() => {
nextTick(() => {
setTimeout(() => {
initEditor();
}, 100);
});
});
onBeforeUnmount(() => {
isDestroyed = true;
if (editorInstance) {
editorInstance.destroy();
editorInstance = null;
}
});
</script>
<style scoped lang="less">
.wang-editor-wrapper {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
background: var(--el-bg-color);
transition: border-color 0.3s ease;
&.focused {
border-color: var(--el-color-primary);
}
.toolbar-container {
position: sticky;
top: 0;
z-index: 10;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
flex-shrink: 0;
}
.editor-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 3px;
&:hover {
background: var(--el-border-color-darker);
}
}
&::-webkit-scrollbar-track {
background: var(--el-fill-color-light);
border-radius: 3px;
}
}
:deep(.w-e-text),
:deep(.w-e-text-container) {
padding: 16px;
min-height: 400px;
p {
color: var(--el-text-color-primary) !important;
margin: 8px 0 !important;
line-height: 1.8 !important;
}
h1, h2, h3, h4, h5, h6 {
color: var(--el-text-color-primary) !important;
font-weight: 600 !important;
margin: 16px 0 8px !important;
}
a {
color: var(--el-color-primary) !important;
text-decoration: underline !important;
}
code {
background: var(--el-fill-color-light) !important;
color: var(--el-text-color-primary) !important;
border: 1px solid var(--el-border-color) !important;
border-radius: 3px;
padding: 2px 6px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
}
pre {
background: var(--el-fill-color-light) !important;
border: 1px solid var(--el-border-color) !important;
border-radius: 4px;
padding: 12px 16px;
margin: 12px 0;
overflow-x: auto;
code {
background: transparent !important;
border: none !important;
padding: 0;
font-size: 13px;
line-height: 1.6;
}
}
blockquote {
border-left: 4px solid var(--el-color-primary) !important;
background: var(--el-fill-color-light) !important;
color: var(--el-text-color-regular) !important;
padding: 8px 16px;
margin: 12px 0;
}
table {
border-collapse: collapse !important;
border: 1px solid var(--el-border-color) !important;
width: 100% !important;
margin: 12px 0;
th, td {
border: 1px solid var(--el-border-color) !important;
padding: 8px 12px !important;
min-width: 60px;
color: var(--el-text-color-primary) !important;
}
th {
background: var(--el-fill-color-light) !important;
font-weight: 600;
}
}
ul, ol {
color: var(--el-text-color-primary) !important;
padding-left: 24px !important;
margin: 8px 0;
}
li {
color: var(--el-text-color-primary) !important;
line-height: 1.8 !important;
margin: 4px 0;
}
hr {
border-top: 1px solid var(--el-border-color) !important;
margin: 16px 0 !important;
}
img {
max-width: 100% !important;
border-radius: 4px !important;
margin: 8px 0;
}
}
}
</style>
@@ -0,0 +1,148 @@
<template>
<div class="note-editor-container">
<div class="editor-header">
<el-input
v-model="noteTitle"
placeholder="请输入标题..."
class="title-input"
@blur="handleTitleChange"
/>
<div class="editor-actions">
<el-button type="primary" @click="handleSave">
<el-icon><DocumentChecked /></el-icon>
保存
</el-button>
</div>
</div>
<div class="editor-body">
<WangEditor v-model="noteContent" />
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue';
import { DocumentChecked } from '@element-plus/icons-vue';
import WangEditor from './WangEditor.vue';
const props = defineProps({
noteId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['save', 'update-title']);
const noteTitle = ref('');
const noteContent = ref('');
// API
const loadNote = () => {
// 使 props.noteId API
// notes
noteTitle.value = '新建笔记';
noteContent.value = '';
};
//
const handleSave = () => {
emit('save', {
id: props.noteId,
title: noteTitle.value,
content: noteContent.value,
});
};
//
const handleTitleChange = () => {
emit('update-title', {
id: props.noteId,
title: noteTitle.value,
});
};
// ID
watch(
() => props.noteId,
() => {
loadNote();
},
{ immediate: true }
);
//
let autoSaveTimer = null;
watch([noteTitle, noteContent], () => {
if (autoSaveTimer) {
clearTimeout(autoSaveTimer);
}
autoSaveTimer = setTimeout(() => {
//
// handleSave();
}, 3000);
});
onMounted(() => {
loadNote();
});
</script>
<style scoped lang="less">
.note-editor-container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--el-bg-color);
}
.editor-header {
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
display: flex;
align-items: center;
gap: 16px;
background: var(--el-fill-color-light);
.title-input {
flex: 1;
:deep(.el-input__wrapper) {
background: var(--el-bg-color);
box-shadow: 0 0 0 1px var(--el-border-color) inset;
padding: 8px 12px;
&:hover {
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
}
&.is-focus {
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
}
}
:deep(.el-input__inner) {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.editor-actions {
display: flex;
gap: 8px;
}
}
.editor-body {
flex: 1;
padding: 20px;
overflow: hidden;
:deep(.wang-editor-wrapper) {
height: 100%;
}
}
</style>
+381
View File
@@ -0,0 +1,381 @@
<template>
<div class="notebook-container">
<div class="notebook-sidebar">
<div class="sidebar-header">
<h3><i class="fa-solid fa-book"></i> 我的笔记</h3>
<el-button type="primary" size="small" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建笔记
</el-button>
</div>
<div class="search-box">
<el-input
v-model="searchKeyword"
placeholder="搜索笔记..."
:prefix-icon="Search"
clearable
@input="handleSearch"
/>
</div>
<div class="notes-list">
<div
v-for="note in filteredNotes"
:key="note.id"
:class="['note-item', { active: currentNoteId === note.id }]"
@click="handleSelectNote(note)"
>
<div class="note-item-header">
<span class="note-title">{{ note.title || '无标题' }}</span>
<el-dropdown trigger="click" @command="(cmd) => handleNoteAction(cmd, note)">
<el-icon class="note-more"><MoreFilled /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="delete">
<el-icon><Delete /></el-icon> 删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="note-preview">{{ getPreviewText(note.content) }}</div>
<div class="note-time">{{ formatTime(note.updated_at) }}</div>
</div>
<el-empty v-if="filteredNotes.length === 0" description="暂无笔记" />
</div>
</div>
<div class="notebook-editor">
<NoteEditor
v-if="currentNoteId"
:note-id="currentNoteId"
@save="handleSave"
@update-title="handleUpdateTitle"
/>
<div v-else class="empty-editor">
<el-empty description="请选择或创建一个笔记" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Search, Delete, MoreFilled } from '@element-plus/icons-vue';
import NoteEditor from './components/edit.vue';
const searchKeyword = ref('');
const currentNoteId = ref(null);
const notes = ref([]);
// - 使 API
const mockNotes = [
{
id: 1,
title: '欢迎使用笔记本',
content: '<p>这是一个功能丰富的笔记应用</p><p>支持富文本编辑、图片上传等功能</p>',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
];
//
const filteredNotes = computed(() => {
if (!searchKeyword.value) return notes.value;
const keyword = searchKeyword.value.toLowerCase();
return notes.value.filter(note => {
return (
note.title?.toLowerCase().includes(keyword) ||
getPreviewText(note.content).toLowerCase().includes(keyword)
);
});
});
//
const getPreviewText = (html) => {
if (!html) return '暂无内容';
const text = html.replace(/<[^>]+>/g, '').trim();
return text.substring(0, 60) + (text.length > 60 ? '...' : '');
};
//
const formatTime = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) {
return '刚刚';
} else if (diff < hour) {
return `${Math.floor(diff / minute)} 分钟前`;
} else if (diff < day) {
return `${Math.floor(diff / hour)} 小时前`;
} else if (diff < 7 * day) {
return `${Math.floor(diff / day)} 天前`;
} else {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
};
//
const handleCreate = () => {
const newNote = {
id: Date.now(),
title: '新建笔记',
content: '',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
notes.value.unshift(newNote);
currentNoteId.value = newNote.id;
ElMessage.success('创建成功');
};
//
const handleSelectNote = (note) => {
currentNoteId.value = note.id;
};
//
const handleSearch = () => {
// computed
};
//
const handleNoteAction = async (command, note) => {
if (command === 'delete') {
try {
await ElMessageBox.confirm('确定要删除这条笔记吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
const index = notes.value.findIndex(n => n.id === note.id);
if (index > -1) {
notes.value.splice(index, 1);
if (currentNoteId.value === note.id) {
currentNoteId.value = notes.value[0]?.id || null;
}
ElMessage.success('删除成功');
}
} catch {
//
}
}
};
//
const handleSave = ({ id, content }) => {
const note = notes.value.find(n => n.id === id);
if (note) {
note.content = content;
note.updated_at = new Date().toISOString();
ElMessage.success('保存成功');
}
};
//
const handleUpdateTitle = ({ id, title }) => {
const note = notes.value.find(n => n.id === id);
if (note) {
note.title = title;
note.updated_at = new Date().toISOString();
}
};
onMounted(() => {
// - 使 API
notes.value = [...mockNotes];
if (notes.value.length > 0) {
currentNoteId.value = notes.value[0].id;
}
});
</script>
<style scoped lang="less">
.notebook-container {
display: flex;
height: calc(100vh - 180px);
gap: 16px;
background: var(--el-bg-color);
border-radius: 8px;
overflow: hidden;
}
.notebook-sidebar {
width: 300px;
display: flex;
flex-direction: column;
background: var(--el-bg-color-overlay);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
overflow: hidden;
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--el-fill-color-light);
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
i {
margin-right: 8px;
color: var(--el-color-primary);
}
}
}
.search-box {
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.notes-list {
flex: 1;
overflow-y: auto;
padding: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 3px;
&:hover {
background: var(--el-border-color-darker);
}
}
}
}
.note-item {
padding: 12px;
margin-bottom: 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid transparent;
background: var(--el-bg-color);
&:hover {
background: var(--el-fill-color-light);
border-color: var(--el-border-color);
}
&.active {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
.note-title {
color: var(--el-color-primary);
font-weight: 600;
}
}
.note-item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
.note-title {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-more {
margin-left: 8px;
color: var(--el-text-color-secondary);
cursor: pointer;
padding: 4px;
border-radius: 4px;
&:hover {
background: var(--el-fill-color);
color: var(--el-text-color-primary);
}
}
}
.note-preview {
font-size: 13px;
color: var(--el-text-color-secondary);
line-height: 1.5;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.note-time {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.notebook-editor {
flex: 1;
background: var(--el-bg-color-overlay);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
overflow: hidden;
.empty-editor {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
//
@media (max-width: 768px) {
.notebook-container {
flex-direction: column;
height: auto;
}
.notebook-sidebar {
width: 100%;
height: 300px;
}
.notebook-editor {
height: 500px;
}
}
</style>