增加公告图片编辑, 增加取消归档
This commit is contained in:
@@ -173,6 +173,22 @@ func (h *Handler) Archive(c *gin.Context) {
|
||||
response.OK(c, gin.H{"message": "归档成功"})
|
||||
}
|
||||
|
||||
// Unarchive 取消归档公告
|
||||
func (h *Handler) Unarchive(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "公告ID不正确")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Unarchive(c.Request.Context(), id); err != nil {
|
||||
response.InternalServerError(c, "取消归档公告失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{"message": "取消归档成功"})
|
||||
}
|
||||
|
||||
// Delete 删除公告
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
|
||||
@@ -179,6 +179,17 @@ func (r *Repository) Archive(ctx context.Context, id uint64) error {
|
||||
Update("status", "archived").Error
|
||||
}
|
||||
|
||||
// Unarchive 取消归档公告,恢复为已发布状态
|
||||
func (r *Repository) Unarchive(ctx context.Context, id uint64) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&model.Announcement{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"status": "published",
|
||||
"published_at": gorm.Expr("COALESCE(published_at, ?)", now),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// Delete 删除公告
|
||||
func (r *Repository) Delete(ctx context.Context, id uint64) error {
|
||||
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Announcement{}).Error
|
||||
|
||||
@@ -95,6 +95,11 @@ func (s *Service) Archive(ctx context.Context, id uint64) error {
|
||||
return s.repo.Archive(ctx, id)
|
||||
}
|
||||
|
||||
// Unarchive 取消归档公告
|
||||
func (s *Service) Unarchive(ctx context.Context, id uint64) error {
|
||||
return s.repo.Unarchive(ctx, id)
|
||||
}
|
||||
|
||||
// Delete 删除公告
|
||||
func (s *Service) Delete(ctx context.Context, id uint64) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
|
||||
@@ -65,7 +65,7 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
||||
response.BadRequest(c, "文件 key 不正确")
|
||||
return
|
||||
}
|
||||
if publicOnly && !strings.HasPrefix(key, "home-banner/") && !strings.HasPrefix(key, "avatar/") && !strings.HasPrefix(key, "payment-cert/") {
|
||||
if publicOnly && !strings.HasPrefix(key, "home-banner/") && !strings.HasPrefix(key, "avatar/") && !strings.HasPrefix(key, "payment-cert/") && !strings.HasPrefix(key, "announcement/") {
|
||||
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ func normalizeContentType(contentType string, data []byte) string {
|
||||
|
||||
func fileURLForScene(scene string, key string) string {
|
||||
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
|
||||
if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" {
|
||||
if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" {
|
||||
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
|
||||
}
|
||||
return fileURL
|
||||
@@ -115,7 +115,7 @@ func fileURLForScene(scene string, key string) string {
|
||||
func normalizeScene(scene string) string {
|
||||
scene = strings.TrimSpace(strings.ToLower(scene))
|
||||
switch scene {
|
||||
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert":
|
||||
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement":
|
||||
return scene
|
||||
default:
|
||||
return "misc"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package file
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeObjectMetadataEscapesNonASCII(t *testing.T) {
|
||||
got := sanitizeObjectMetadata(map[string]string{
|
||||
@@ -11,3 +14,18 @@ func TestSanitizeObjectMetadataEscapesNonASCII(t *testing.T) {
|
||||
t.Fatalf("metadata filename = %q", got["original-filename"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncementSceneUsesPublicFileURL(t *testing.T) {
|
||||
key := "announcement/2026/06/15/example.webp"
|
||||
got := fileURLForScene("announcement", key)
|
||||
|
||||
if !strings.HasPrefix(got, "/api/public/files/object?") {
|
||||
t.Fatalf("announcement file url = %q, want public file endpoint", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSceneAllowsAnnouncement(t *testing.T) {
|
||||
if got := normalizeScene("announcement"); got != "announcement" {
|
||||
t.Fatalf("normalize announcement scene = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,6 +570,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.PUT("/announcements/:id", requirePerm("announcement:manage"), announcementHandler.Update)
|
||||
adminRoutes.POST("/announcements/:id/publish", requirePerm("announcement:manage"), announcementHandler.Publish)
|
||||
adminRoutes.POST("/announcements/:id/archive", requirePerm("announcement:manage"), announcementHandler.Archive)
|
||||
adminRoutes.POST("/announcements/:id/unarchive", requirePerm("announcement:manage"), announcementHandler.Unarchive)
|
||||
adminRoutes.DELETE("/announcements/:id", requirePerm("announcement:manage"), announcementHandler.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
支付方式
|
||||
聚合支付
|
||||
聚合收银台
|
||||
收银台订单创建
|
||||
收银台订单创建
|
||||
更新时间:2026-04-28 17:42:56
|
||||
调用地址
|
||||
注:因银网联测试环境问题,微信钱包在测试环境下无法下单完成支付,下单后提示"sub mch id与sub appid不匹配"报错即可。
|
||||
|
||||
自2024年1月16日起,请通过以下接口进行接入:
|
||||
|
||||
使用HTTP协议,POST方式提交。
|
||||
|
||||
URL(测试环境外网):https://test.wsmsd.cn/sit/api/v3/ccss/counter/order/special_create
|
||||
|
||||
URL(生产环境):https://s2.lakala.com/api/v3/ccss/counter/order/special_create
|
||||
|
||||
商户需在微信商家后台配置以下支付域名(原支付目录),2023 年 9 月后入网的商户可忽略此配置:
|
||||
|
||||
生产环境订单域名:pay.lakala.com测试环境订单域名:pay.wsmsd.cn
|
||||
|
||||
请求参数
|
||||
字段名 是否必输 类型 长度 字段描述 示例
|
||||
out_order_no M String 32 商户订单号 12345678
|
||||
merchant_no M String 32 银联商户号 822100041120005
|
||||
vpos_id C String 32 交易设备标识,进件返回接口中的termId字段,非API接口进件请联系业务员。 462621830268882944
|
||||
channel_id C String 32 渠道号 (一般不用) 24865454154
|
||||
total_amount M long 12 订单金额,单位:分 200
|
||||
order_efficient_time M String 14 订单有效期 格式yyyyMMddHHmmss,最大支持下单时间+7天 20210803141700
|
||||
notify_url C String 128 订单支付成功后商户接收订单通知的地址 http://xxx.xxx.com
|
||||
support_cancel C int 1 是否支持撤销 默认 0 不支持
|
||||
busi_mode为"PAY-付款"不支持 撤销 (0 不支持 1支持)
|
||||
support_refund C int 1 是否支持退款 默认0 不支持 (0 不支持 1支持)
|
||||
support_repeat_pay C int 1 是否支持"多次发起支付" 默认0 不支持 (0 不支持 1支持)
|
||||
out_user_id C String 64 发起订单方的userId,归属于channelId下的userId
|
||||
callback_url C String 128 客户端下单完成支付后返回的商户网页跳转地址。
|
||||
order_info M String 64
|
||||
订单标题,在使用收银台扫码支付时必输入,交易时送往账户端
|
||||
|
||||
*该字段暂不支持颜文字、表情符号,比如微笑表情等
|
||||
|
||||
|
||||
term_no C String 32 结算终端号,合单场景必输该字段
|
||||
split_mark C String 2 合单标识,"1"为合单,不填默认是为非合单
|
||||
settle_type C String 4
|
||||
结算类型(非合单) "0"或者空,常规结算方式;如需接拉卡拉分账通需传"1",商户未开通分账之前切记不用上送此参数,如需订单结算需传"2"
|
||||
|
||||
|
||||
out_split_info C List<>
|
||||
拆单信息
|
||||
合单标识为"1"时必传该字段。,详细字段见out_split_info字段说明
|
||||
counter_param C String 1024 json字符串 收银台展示参数 {\"pay_mode\" : \"ALIPAY\"} ,指定支付方式为支付宝
|
||||
ALIPAY:支付宝
|
||||
WECHAT:微信
|
||||
UNION:银联云闪付
|
||||
CARD:POS刷卡交易
|
||||
LKLAT:线上转帐
|
||||
QUICK_PAY:快捷支付
|
||||
EBANK:网银支付
|
||||
UNION_CC:银联支付
|
||||
BESTPAY:翼支付
|
||||
HB_FQ:花呗分期
|
||||
UNION_FQ:银联聚分期
|
||||
|
||||
ONLINE_CARDLESS:线上外卡
|
||||
|
||||
JDBT:京东白条
|
||||
|
||||
ALIPAY_HK:支付宝香港钱包支付
|
||||
|
||||
若要指定支付方式为支付宝传参格式:
|
||||
{\"pay_mode\" : \"ALIPAY\"}
|
||||
|
||||
counter_remark
|
||||
C String 128 收银台备注
|
||||
busi_type_param C String 256 业务类型控制参数,jsonStr格式 [{\"busi_type\":\"UPCARD\",\"params\":{\"crd_flg\":\"CRDFLG_D|CRDFLG_C|CRDFLG_OTH\"}},{\"busi_type\":\"SCPAY\",\"params\":{\"pay_mode\":\"ALIPAY\",\"crd_flg\":\"CRDFLG_D\"}}]
|
||||
说明:UPCARD-刷卡,SCPAY-扫码,CRDFLG_D-借记卡,CRDFLG_C-贷记卡,CRDFLG_OTH-不明确是借记卡还是贷记卡
|
||||
pay_mode送参说明:ALIPAY-支付宝,WECHAT-微信,UNION-银联二维码,DCPAY-数字货币,BESTPAY-翼支付
|
||||
说明:一旦使用该字段,则增加限制,必须在指定限制范围内支付。比如,只配置"busi_type":"UPCARD"的参数而不配置"busi_type":"SCPAY"的参数,则只能通过刷卡而不能通过扫码完成支付
|
||||
|
||||
sgn_info C list<>
|
||||
签约协议号列表(字符串) ["1234","2345"],不支持空列表[];列表中签约协议号不能为空;列表中签约协议号不能重复
|
||||
product_id
|
||||
C String 6 指定产品编号 (200809:线上外卡收银台) 注意:该字段默认不需要指定,特殊场景下使用,慎用
|
||||
goods_mark C String
|
||||
商品信息标识 (1:含商品信息,不填默认不含商品信息)
|
||||
goods_field C String 2 商品信息域(good_mark送1时该域必填,否则不送。只有线上外卡业务上送该字段) 详细字段见goods_field字段说明
|
||||
order_scene_field C Object
|
||||
2 订单场景域,特殊场景下需要上送 详细字段见order_scene_field字段说明
|
||||
age_limit C String 1 0:不限年龄;1:年龄限制
|
||||
repeat_pay_auto_refund C String 1
|
||||
0:重复支付后不自动退货;1:重复支付后自动退货 (默认不送为0),注意:请详细了解字段场景后上送
|
||||
|
||||
需注意互斥条件:repeat_pay_auto_refund选择"1"重复支付后自动退货后,repeat_pay_notify仅支持选择"0"重复支付订单不通知
|
||||
|
||||
|
||||
repeat_pay_notify C String 1 0:重复支付订单不通知;1:重复支付订单通知 (默认不送为0)
|
||||
close_order_auto_refund C String 1 0:不自动退货;1:关闭订单后支付成功触发自动退货 (默认不送为0)注意:请详细了解字段场景后上送
|
||||
shop_name C String 64 网点名称
|
||||
inte_routing C String 2 智能路由下单标识 1-是 0-否(默认不送为0)备注:需要在收银台管控台配置聚合收银台小程序白名单
|
||||
discount_code C String 64 优惠码(目前供线上国补下单使用)
|
||||
electrical_equipment_category C
|
||||
String
|
||||
128 支付宝优惠码(目前优惠码的地区:浙江、江苏、上海、福建、重庆)
|
||||
trade_biz_tp C String 16 线上业务通道类型
|
||||
具体类型见补充枚举
|
||||
|
||||
* 网银支付、快捷支付必传此字段
|
||||
|
||||
cash_pay_show_mode C
|
||||
String 32 对公支付标识 LQF-对公支付
|
||||
out_split_info字段说明
|
||||
字段名 中文名称 是否必填 类型 说明
|
||||
out_sub_order_no 外部子订单号 M String(32) 商户子订单号
|
||||
merchant_no 商户号 M String(32) 拉卡拉分配的银联商户号
|
||||
term_no 终端号 M String(32) 拉卡拉分配的业务终端号
|
||||
amount 金额 M String(12) 单位分,整数型字符
|
||||
settle_type 结算类型(合单) C String(4) "0"或者空,常规结算方式
|
||||
说明:
|
||||
|
||||
1)拆单信息域中商户号不可重复;
|
||||
|
||||
2)交易层订单金额必须是拆单信息域中各个子单的金额汇总之和;
|
||||
|
||||
3)对拆单信息域中每个结算商户号和终端号的权限交易都必须通过,其中一个校验失败,则交易中止,失败返回;
|
||||
|
||||
4)拆单域中子单条数最少两条、最多50条,否则拒绝。
|
||||
|
||||
goods_field字段说明
|
||||
字段名 中文名称 是否必填 类型 说明
|
||||
goods_amt 商品单价 M Long 单位:分
|
||||
goods_num 商品数量 M Integer
|
||||
goods_pricing_unit 商品计价单位 M String(8) 1-箱 2-件 3-瓶 4-个
|
||||
goods_name 商品名称 M String(128)
|
||||
te_platform_type 交易电商平台类型 M String(2) 1-境内平台 2-境外平台
|
||||
te_platform_name 交易电商平台名称 M String(256)
|
||||
goods_type 交易商品类型 M String(8) 1:服饰箱包
|
||||
2:食品药品
|
||||
3:化妆品
|
||||
4:电子产品
|
||||
5:日用家居
|
||||
7:航空机票
|
||||
8:酒店住宿
|
||||
9:留学教育
|
||||
10:旅游票务
|
||||
11:国际物流
|
||||
12:国际租车
|
||||
13:国际会议
|
||||
14:软件服务
|
||||
15:医疗服务
|
||||
16:通讯
|
||||
17:休闲娱乐
|
||||
order_scene_field字段说明
|
||||
字段名 中文名称 是否必填 类型 说明
|
||||
order_scene_type 订单场景类型 M String(16) 订单场景类型(按下述定义场景送值)
|
||||
HB_FQ:花呗分期场景
|
||||
|
||||
KL_FQ:考拉分期场景
|
||||
|
||||
scene_info 订单场景信息 C String(1024) 订单场景信息(json字符串格式),不同的订单场景类型需要上送的结构不一样(详见具体场景)
|
||||
HB_FQ场景
|
||||
scene_info字段说明
|
||||
字段名 中文名称 是否必填 类型 说明
|
||||
hbFqNum 花呗分期期数 M String 支付宝花呗分期必送字段: 花呗分期数 3:3期 6:6期 12:12期
|
||||
hbFqSellerPercent 卖家承担手续费比例 M String 支付宝花呗分期必送字段: 卖家承担手续费比例,间连模式下只支持传0。
|
||||
JDBT场景
|
||||
scene_info字段说明
|
||||
字段名 中文名称 是否必填 类型 说明
|
||||
LOCKPLAN
|
||||
|
||||
String
|
||||
->jdbtFqNum 京东白条分期期数 M String 京东白条分期数 3:3期,6:6期,12:12期 ,24:24期
|
||||
trade_biz_tp字段说明
|
||||
拉卡拉业务种类编码 编码含义
|
||||
100001 虚拟商品购买
|
||||
100002 预付费类账户充值
|
||||
100003 实物消费
|
||||
100004 航空商旅消费
|
||||
100005 生活及商业服务消费
|
||||
100006 其他商户消费
|
||||
100007 招投标保证金支付
|
||||
100008 境外商品购买
|
||||
100A01 实物商品租赁
|
||||
100A03 单用途预付卡充值
|
||||
100A06 商业服务消费
|
||||
100A05 航旅交通服务
|
||||
100A07 生活服务消费
|
||||
100A08 个人经营服务
|
||||
110001 公共事业缴费
|
||||
110002 教育医疗缴费
|
||||
110003 政府服务缴费
|
||||
110004 公益捐款
|
||||
110005 农林牧副渔收购
|
||||
110006 政府服务
|
||||
110007 薪资发放
|
||||
110008 其他公共服务
|
||||
110A01 水电煤气缴费
|
||||
110A02 税费缴纳
|
||||
110A03 非营利性教育缴费
|
||||
110A05 罚款缴纳
|
||||
110A06 路桥通行缴费
|
||||
110A07 邮政缴费
|
||||
110A08 电视账单缴费
|
||||
110A09 话费账单缴费
|
||||
110A10 宽带账单缴费
|
||||
110A13 财政非税收入
|
||||
110A14 营利性教育培训
|
||||
110A15 公共交通
|
||||
110A16 急救救援
|
||||
110A17 物业缴费
|
||||
110A18 国库经收
|
||||
110A19 供暖费缴纳
|
||||
110A20 废弃物处理费用缴纳
|
||||
110A21 租金缴纳
|
||||
110A22 会员费用缴纳
|
||||
110A23 税费退还
|
||||
120001 其他金融付款
|
||||
120002 其他金融收款
|
||||
120003 基金购买
|
||||
120004 保险选购
|
||||
120005 投资理财
|
||||
120006 信贷偿还
|
||||
120007 信用卡还款转出
|
||||
120008 基金赎回/返还/分红
|
||||
120009 保险理赔/分红
|
||||
120010 投资理财赎回/返还/分红
|
||||
120011 信贷发放
|
||||
120012 信用卡还款转入
|
||||
120A01 基金理财产品申购
|
||||
120A02 基金理财产品认购
|
||||
120A03 非投资型保险费用缴纳
|
||||
120A05 商业众筹
|
||||
120A06 贵金属投资买入
|
||||
120A07 基金理财产品赎回
|
||||
120A08 基金理财产品到期返还
|
||||
120A09 认/申购失败返还
|
||||
120A10 基金理财产品分红
|
||||
120A11 保险理赔或退费
|
||||
120A12 保险红利发放或给付发放
|
||||
120A13 贵金属投资买出
|
||||
120A16 融资租赁
|
||||
120A19 投资型保险费用缴纳
|
||||
120A20 小贷公司贷款还款
|
||||
120A21 保单贷款发放
|
||||
120A22 其他保险资金代发
|
||||
130001 支付账户充值
|
||||
130002 支付账户回提
|
||||
130003 银行账户转账转出
|
||||
130004 其他账户充值
|
||||
130005 银行账户转账转入
|
||||
130006 其他账户回提
|
||||
130A03 向他人支付账户转账
|
||||
130A05 预付卡赎回 -个人赎回
|
||||
130A06 预付卡赎回-单位赎回
|
||||
130A08 测试验证资金
|
||||
130A09 薪酬福利发放
|
||||
130A10 代发货款
|
||||
140001 商户结算-交易资金结算
|
||||
140002 营销返现
|
||||
140003 其他商户结算
|
||||
140A02 预付卡商户结算
|
||||
140A03 商户收单资金提现
|
||||
150A01 资金归集
|
||||
200000 对公业务
|
||||
|
||||
|
||||
请求样例
|
||||
{
|
||||
"req_data": {
|
||||
"out_order_no": "KFPT20220714160009228907288",
|
||||
"merchant_no": "8222900701106PZ",
|
||||
"vpos_id": "587305941625155584",
|
||||
"channel_id": "2021052614391",
|
||||
"total_amount": "1",
|
||||
"order_efficient_time": "20220714170009",
|
||||
"notify_url": "http://run.mocky.io/v3/b02c9448-20a2-4ff6-a678-38ecab30161d",
|
||||
"support_cancel": "0",
|
||||
"support_refund": "1",
|
||||
"support_repeat_pay": "1",
|
||||
"busi_type_param": "[{\"busi_type\":\"UPCARD\",\"params\":{\"crd_flg\":\"CRDFLG_D|CRDFLG_C|CRDFLG_OTH\"}},{\"busi_type\":\"SCPAY\",\"params\":{\"pay_mode\":\"WECHAT\",\"crd_flg\":\"CRDFLG_D\"}}]",
|
||||
"counter_param": "{\"pay_mode\":\"ALIPAY\"}",
|
||||
"out_user_id": "",
|
||||
"order_info": "自动化测试",
|
||||
"extend_info": "自动化测试",
|
||||
"callback_url": ""
|
||||
},
|
||||
"version": "3.0",
|
||||
"req_time": "20220714160009"}
|
||||
返回参数
|
||||
字段名 是否必输 类型 长度 字段描述 示例
|
||||
merchant_no M String 32
|
||||
银联商户号
|
||||
channel_id M String 32
|
||||
|
||||
out_order_no M String 32 商户订单号
|
||||
order_create_time M String 32 创建订单时间 订单系统创建订单的时间,格式yyyyMMddHHmmss
|
||||
order_efficient_time M String 32 订单有效截至时间 格式yyyyMMddHHmmss
|
||||
pay_order_no M String 64 平台订单号 21070211012001970631000383039
|
||||
total_amount M long 12 订单金额,单位:分 200
|
||||
counter_url M String 256 收银台地址信息
|
||||
响应样例
|
||||
{
|
||||
"msg": "操作成功",
|
||||
"resp_time": "20210922181057",
|
||||
"code": "000000",
|
||||
"resp_data": {
|
||||
"merchant_no": "8222900701106PZ",
|
||||
"channel_id": "25",
|
||||
"out_order_no": "KFPT20220714160009228907288",
|
||||
"order_create_time": "20210922181056",
|
||||
"order_efficient_time": "20210803141700",
|
||||
"pay_order_no": "21092211012001970631000488056",
|
||||
"counter_url": "http://q.huijingcai.top/b/pay?merchantNo=8221210594300JY&merchantOrderNo=08F4542EEC6A4497BC419161747A92FQ&payOrderNo=21092211012001970631000488056"
|
||||
}
|
||||
}
|
||||
返回码code一览表
|
||||
msg code
|
||||
成功 000000
|
||||
Vendored
+1
@@ -49,6 +49,7 @@ declare module 'vue' {
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||
ElStep: typeof import('element-plus/es')['ElStep']
|
||||
ElSteps: typeof import('element-plus/es')['ElSteps']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
|
||||
@@ -59,6 +59,10 @@ export async function archiveAnnouncement(id: number): Promise<void> {
|
||||
await apiClient.post(`/admin/announcements/${id}/archive`)
|
||||
}
|
||||
|
||||
export async function unarchiveAnnouncement(id: number): Promise<void> {
|
||||
await apiClient.post(`/admin/announcements/${id}/unarchive`)
|
||||
}
|
||||
|
||||
export async function deleteAnnouncement(id: number): Promise<void> {
|
||||
await apiClient.delete(`/admin/announcements/${id}`)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Bell, Document, QuestionFilled, Warning } from '@element-plus/icons-vue'
|
||||
import { ElInput, ElMessage } from 'element-plus'
|
||||
import { Bell, Document, Operation, Picture, QuestionFilled, Warning } from '@element-plus/icons-vue'
|
||||
import type { Announcement } from '@/features/announcement'
|
||||
import type {
|
||||
CreateAnnouncementRequest,
|
||||
UpdateAnnouncementRequest,
|
||||
} from '@/features/admin/api/adminAnnouncements'
|
||||
import { uploadAdminFile } from '@/shared/api/files'
|
||||
import { debugError } from '@/shared/utils/debug'
|
||||
import { readError } from '@/shared/utils/error'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
@@ -22,6 +25,17 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const contentInputRef = ref<InstanceType<typeof ElInput>>()
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null)
|
||||
const uploadingImage = ref(false)
|
||||
const imageSettingsVisible = ref(false)
|
||||
const imageSettingsRange = ref<{ start: number; end: number } | null>(null)
|
||||
const imageSettingsForm = ref({
|
||||
alt: '',
|
||||
url: '',
|
||||
width: 720,
|
||||
useCustomWidth: true,
|
||||
})
|
||||
const formData = ref<CreateAnnouncementRequest>({
|
||||
title: '',
|
||||
content: '',
|
||||
@@ -51,6 +65,14 @@ const dialogTitle = computed(() => {
|
||||
return props.mode === 'create' ? '新建公告' : '编辑公告'
|
||||
})
|
||||
|
||||
const imageSettingsPreviewStyle = computed(() => {
|
||||
if (!imageSettingsForm.value.useCustomWidth) return {}
|
||||
return {
|
||||
width: `${imageSettingsForm.value.width}px`,
|
||||
maxWidth: '100%',
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
val => {
|
||||
@@ -93,6 +115,153 @@ async function handleSave() {
|
||||
debugError('表单验证失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
function triggerImageUpload() {
|
||||
if (uploadingImage.value) return
|
||||
imageInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleImageChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || uploadingImage.value) return
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||
ElMessage.warning('仅支持 JPG、PNG 或 WebP 图片')
|
||||
return
|
||||
}
|
||||
|
||||
uploadingImage.value = true
|
||||
try {
|
||||
const uploaded = await uploadAdminFile(file, 'announcement')
|
||||
insertContentAtCursor(``)
|
||||
ElMessage.success('图片已插入')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '图片上传失败'))
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function insertContentAtCursor(markdown: string) {
|
||||
const textarea = contentInputRef.value?.textarea
|
||||
const content = formData.value.content || ''
|
||||
if (!textarea) {
|
||||
formData.value.content = appendMarkdown(content, markdown)
|
||||
return
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart ?? content.length
|
||||
const end = textarea.selectionEnd ?? content.length
|
||||
const before = content.slice(0, start)
|
||||
const after = content.slice(end)
|
||||
const prefix = before === '' || before.endsWith('\n') ? '' : '\n\n'
|
||||
const suffix = after === '' || after.startsWith('\n') ? '' : '\n\n'
|
||||
const insertion = `${prefix}${markdown}${suffix}`
|
||||
formData.value.content = `${before}${insertion}${after}`
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const cursor = start + insertion.length
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
function appendMarkdown(content: string, markdown: string) {
|
||||
if (!content) return markdown
|
||||
return `${content}${content.endsWith('\n') ? '' : '\n\n'}${markdown}`
|
||||
}
|
||||
|
||||
function imageAltText(filename: string) {
|
||||
return filename.replace(/\.[^.]+$/, '') || '公告图片'
|
||||
}
|
||||
|
||||
function openImageSettings() {
|
||||
const textarea = contentInputRef.value?.textarea
|
||||
const cursor = textarea?.selectionStart ?? formData.value.content.length
|
||||
const image = findImageMarkdownAtCursor(formData.value.content, cursor)
|
||||
if (!image) {
|
||||
ElMessage.warning('请先把光标放在一张图片语法中')
|
||||
return
|
||||
}
|
||||
|
||||
imageSettingsRange.value = { start: image.start, end: image.end }
|
||||
imageSettingsForm.value = {
|
||||
alt: image.alt,
|
||||
url: image.url,
|
||||
width: image.width || 720,
|
||||
useCustomWidth: Boolean(image.width),
|
||||
}
|
||||
imageSettingsVisible.value = true
|
||||
}
|
||||
|
||||
function applyImageSettings() {
|
||||
const range = imageSettingsRange.value
|
||||
if (!range) return
|
||||
const alt = imageSettingsForm.value.alt.trim() || '公告图片'
|
||||
const url = imageSettingsForm.value.url.trim()
|
||||
if (!url) {
|
||||
ElMessage.warning('图片地址不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
const widthMark = imageSettingsForm.value.useCustomWidth ? `|w=${imageSettingsForm.value.width}` : ''
|
||||
const nextMarkdown = ``
|
||||
const content = formData.value.content
|
||||
formData.value.content = `${content.slice(0, range.start)}${nextMarkdown}${content.slice(range.end)}`
|
||||
imageSettingsVisible.value = false
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = contentInputRef.value?.textarea
|
||||
if (!textarea) return
|
||||
const cursor = range.start + nextMarkdown.length
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
function setImageWidth(width: number | null) {
|
||||
if (width === null) {
|
||||
imageSettingsForm.value.useCustomWidth = false
|
||||
return
|
||||
}
|
||||
imageSettingsForm.value.width = width
|
||||
imageSettingsForm.value.useCustomWidth = true
|
||||
}
|
||||
|
||||
function findImageMarkdownAtCursor(content: string, cursor: number) {
|
||||
const imagePattern = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = imagePattern.exec(content))) {
|
||||
const start = match.index
|
||||
const end = start + match[0].length
|
||||
if (cursor < start || cursor > end) continue
|
||||
|
||||
const imageText = parseImageText(match[1] || '')
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
alt: imageText.alt,
|
||||
width: imageText.width,
|
||||
url: match[2] || '',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseImageText(text: string) {
|
||||
const matched = text.match(/^(.*?)(?:\|w=(\d{2,4}))$/)
|
||||
if (!matched) return { alt: text, width: null }
|
||||
const width = Number(matched[2])
|
||||
return {
|
||||
alt: matched[1] || '',
|
||||
width: Number.isFinite(width) ? width : null,
|
||||
}
|
||||
}
|
||||
|
||||
function escapeMarkdownImageText(text: string) {
|
||||
return text.replace(/\[/g, '').replace(/\]/g, '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -122,12 +291,32 @@ async function handleSave() {
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="公告内容" prop="content">
|
||||
<el-input
|
||||
v-model="formData.content"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="支持 Markdown 或 HTML 格式,可以使用标题、列表、链接等"
|
||||
/>
|
||||
<div class="content-editor">
|
||||
<div class="content-toolbar">
|
||||
<el-button size="small" :loading="uploadingImage" @click="triggerImageUpload">
|
||||
<el-icon><Picture /></el-icon>
|
||||
插入图片
|
||||
</el-button>
|
||||
<el-button size="small" @click="openImageSettings">
|
||||
<el-icon><Operation /></el-icon>
|
||||
图片设置
|
||||
</el-button>
|
||||
<input
|
||||
ref="imageInputRef"
|
||||
class="hidden-file-input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
@change="handleImageChange"
|
||||
/>
|
||||
</div>
|
||||
<el-input
|
||||
ref="contentInputRef"
|
||||
v-model="formData.content"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="支持 Markdown 格式,可以使用标题、列表、链接和图片"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="优先级">
|
||||
@@ -153,9 +342,106 @@ async function handleSave() {
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="imageSettingsVisible" title="图片设置" width="520px">
|
||||
<div class="image-settings-preview">
|
||||
<img
|
||||
v-if="imageSettingsForm.url"
|
||||
:src="imageSettingsForm.url"
|
||||
:alt="imageSettingsForm.alt"
|
||||
:style="imageSettingsPreviewStyle"
|
||||
/>
|
||||
</div>
|
||||
<el-form label-width="84px">
|
||||
<el-form-item label="图片说明">
|
||||
<el-input v-model="imageSettingsForm.alt" placeholder="用于图片替代文字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示宽度">
|
||||
<div class="image-width-control">
|
||||
<el-switch v-model="imageSettingsForm.useCustomWidth" active-text="自定义" inactive-text="原宽" />
|
||||
<el-slider
|
||||
v-model="imageSettingsForm.width"
|
||||
:min="120"
|
||||
:max="1200"
|
||||
:step="10"
|
||||
:disabled="!imageSettingsForm.useCustomWidth"
|
||||
/>
|
||||
<el-input-number
|
||||
v-model="imageSettingsForm.width"
|
||||
:min="120"
|
||||
:max="1200"
|
||||
:step="10"
|
||||
:disabled="!imageSettingsForm.useCustomWidth"
|
||||
controls-position="right"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="快捷尺寸">
|
||||
<div class="quick-widths">
|
||||
<el-button size="small" @click="setImageWidth(320)">小</el-button>
|
||||
<el-button size="small" @click="setImageWidth(720)">中</el-button>
|
||||
<el-button size="small" @click="setImageWidth(960)">大</el-button>
|
||||
<el-button size="small" @click="setImageWidth(null)">原宽</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="imageSettingsVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="applyImageSettings">应用</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.image-settings-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e6eaf2;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.image-settings-preview img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 260px;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.image-width-control {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(160px, 1fr) 120px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quick-widths {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
:deep(.el-textarea__inner) {
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
updateAnnouncement,
|
||||
publishAnnouncement,
|
||||
archiveAnnouncement,
|
||||
unarchiveAnnouncement,
|
||||
deleteAnnouncement,
|
||||
type CreateAnnouncementRequest,
|
||||
type UpdateAnnouncementRequest,
|
||||
@@ -135,6 +136,23 @@ async function handleArchive(item: Announcement) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnarchive(item: Announcement) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认要取消归档该公告吗?取消后公告将恢复发布。', '提示', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await unarchiveAnnouncement(item.id)
|
||||
ElMessage.success('取消归档成功')
|
||||
loadAnnouncements()
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
ElMessage.error('取消归档失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: Announcement) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认要删除该公告吗?此操作不可恢复。', '警告', {
|
||||
@@ -314,6 +332,16 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
|
||||
>
|
||||
归档
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'archived'"
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="handleUnarchive(row)"
|
||||
text
|
||||
style="color: #d97706; font-weight: 700"
|
||||
>
|
||||
取消归档
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Marked } from 'marked'
|
||||
|
||||
const announcementMarkdown = new Marked({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
renderer: {
|
||||
html() {
|
||||
return ''
|
||||
},
|
||||
link({ href, title, tokens }) {
|
||||
const text = this.parser.parseInline(tokens)
|
||||
if (!isSafeMarkdownURL(href)) return text
|
||||
const safeTitle = title ? ` title="${escapeHTMLAttribute(title)}"` : ''
|
||||
return `<a href="${escapeHTMLAttribute(href)}"${safeTitle} target="_blank" rel="noopener noreferrer">${text}</a>`
|
||||
},
|
||||
image({ href, title, text }) {
|
||||
if (!isSafeMarkdownURL(href)) return ''
|
||||
const imageText = parseImageText(text)
|
||||
const safeTitle = title ? ` title="${escapeHTMLAttribute(title)}"` : ''
|
||||
const widthStyle = imageText.width
|
||||
? ` style="width:${imageText.width}px;max-width:100%;height:auto;"`
|
||||
: ''
|
||||
return `<img src="${escapeHTMLAttribute(href)}" alt="${escapeHTMLAttribute(imageText.alt)}"${safeTitle}${widthStyle}>`
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function renderAnnouncementMarkdown(content: string) {
|
||||
return announcementMarkdown.parse(content) as string
|
||||
}
|
||||
|
||||
function isSafeMarkdownURL(url: string) {
|
||||
const value = url.trim().toLowerCase()
|
||||
return (
|
||||
value.startsWith('/') ||
|
||||
value.startsWith('http://') ||
|
||||
value.startsWith('https://') ||
|
||||
value.startsWith('mailto:')
|
||||
)
|
||||
}
|
||||
|
||||
function escapeHTMLAttribute(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function parseImageText(text: string) {
|
||||
const matched = text.match(/^(.*?)(?:\|w=(\d{2,4}))$/)
|
||||
if (!matched) return { alt: text }
|
||||
|
||||
const width = Number(matched[2])
|
||||
if (!Number.isFinite(width) || width < 120 || width > 1200) return { alt: text }
|
||||
return {
|
||||
alt: matched[1] || '',
|
||||
width,
|
||||
}
|
||||
}
|
||||
@@ -11,25 +11,19 @@ import {
|
||||
Warning,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { fetchAnnouncementDetail, type Announcement } from '@/features/announcement'
|
||||
import { renderAnnouncementMarkdown } from '@/features/announcement/utils/markdown'
|
||||
import { debugError } from '@/shared/utils/debug'
|
||||
import { formatDateTime } from '@/shared/utils/time'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const announcement = ref<Announcement | null>(null)
|
||||
|
||||
// 配置 marked 选项
|
||||
marked.setOptions({
|
||||
breaks: true, // 支持换行
|
||||
gfm: true, // GitHub Flavored Markdown
|
||||
})
|
||||
|
||||
// 计算属性:将 Markdown 转换为 HTML
|
||||
const renderedContent = computed(() => {
|
||||
if (!announcement.value?.content) return ''
|
||||
return marked.parse(announcement.value.content)
|
||||
return renderAnnouncementMarkdown(announcement.value.content)
|
||||
})
|
||||
|
||||
const categories = [
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { marked } from 'marked'
|
||||
import { fetchAnnouncementDetail, type Announcement } from '@/features/announcement'
|
||||
import { renderAnnouncementMarkdown } from '@/features/announcement/utils/markdown'
|
||||
import { formatDateTime } from '@/shared/utils/time'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -11,11 +11,6 @@ const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const announcement = ref<Announcement | null>(null)
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
const categories = [
|
||||
{ value: 'notice', label: '通知公告' },
|
||||
{ value: 'tutorial', label: '使用教程' },
|
||||
@@ -25,7 +20,7 @@ const categories = [
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (!announcement.value?.content) return ''
|
||||
return marked.parse(announcement.value.content)
|
||||
return renderAnnouncementMarkdown(announcement.value.content)
|
||||
})
|
||||
|
||||
onMounted(loadAnnouncement)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
|
||||
avatar: 512,
|
||||
chat: 1280,
|
||||
announcement: 1600,
|
||||
'home-banner': 1920,
|
||||
listing: 1920,
|
||||
dispute: 1920,
|
||||
@@ -11,6 +12,7 @@ const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
|
||||
const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
|
||||
avatar: 0.82,
|
||||
chat: 0.8,
|
||||
announcement: 0.84,
|
||||
'home-banner': 0.84,
|
||||
listing: 0.84,
|
||||
dispute: 0.86,
|
||||
|
||||
Reference in New Issue
Block a user