增加公告图片编辑, 增加取消归档
This commit is contained in:
@@ -173,6 +173,22 @@ func (h *Handler) Archive(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"message": "归档成功"})
|
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 删除公告
|
// Delete 删除公告
|
||||||
func (h *Handler) Delete(c *gin.Context) {
|
func (h *Handler) Delete(c *gin.Context) {
|
||||||
id, err := parseID(c)
|
id, err := parseID(c)
|
||||||
|
|||||||
@@ -179,6 +179,17 @@ func (r *Repository) Archive(ctx context.Context, id uint64) error {
|
|||||||
Update("status", "archived").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 删除公告
|
// Delete 删除公告
|
||||||
func (r *Repository) Delete(ctx context.Context, id uint64) error {
|
func (r *Repository) Delete(ctx context.Context, id uint64) error {
|
||||||
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Announcement{}).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)
|
return s.repo.Archive(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unarchive 取消归档公告
|
||||||
|
func (s *Service) Unarchive(ctx context.Context, id uint64) error {
|
||||||
|
return s.repo.Unarchive(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
// Delete 删除公告
|
// Delete 删除公告
|
||||||
func (s *Service) Delete(ctx context.Context, id uint64) error {
|
func (s *Service) Delete(ctx context.Context, id uint64) error {
|
||||||
return s.repo.Delete(ctx, id)
|
return s.repo.Delete(ctx, id)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
|||||||
response.BadRequest(c, "文件 key 不正确")
|
response.BadRequest(c, "文件 key 不正确")
|
||||||
return
|
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", "文件不存在或暂不可访问")
|
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func normalizeContentType(contentType string, data []byte) string {
|
|||||||
|
|
||||||
func fileURLForScene(scene string, key string) string {
|
func fileURLForScene(scene string, key string) string {
|
||||||
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
|
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)
|
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
|
||||||
}
|
}
|
||||||
return fileURL
|
return fileURL
|
||||||
@@ -115,7 +115,7 @@ func fileURLForScene(scene string, key string) string {
|
|||||||
func normalizeScene(scene string) string {
|
func normalizeScene(scene string) string {
|
||||||
scene = strings.TrimSpace(strings.ToLower(scene))
|
scene = strings.TrimSpace(strings.ToLower(scene))
|
||||||
switch 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
|
return scene
|
||||||
default:
|
default:
|
||||||
return "misc"
|
return "misc"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package file
|
package file
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestSanitizeObjectMetadataEscapesNonASCII(t *testing.T) {
|
func TestSanitizeObjectMetadataEscapesNonASCII(t *testing.T) {
|
||||||
got := sanitizeObjectMetadata(map[string]string{
|
got := sanitizeObjectMetadata(map[string]string{
|
||||||
@@ -11,3 +14,18 @@ func TestSanitizeObjectMetadataEscapesNonASCII(t *testing.T) {
|
|||||||
t.Fatalf("metadata filename = %q", got["original-filename"])
|
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.PUT("/announcements/:id", requirePerm("announcement:manage"), announcementHandler.Update)
|
||||||
adminRoutes.POST("/announcements/:id/publish", requirePerm("announcement:manage"), announcementHandler.Publish)
|
adminRoutes.POST("/announcements/:id/publish", requirePerm("announcement:manage"), announcementHandler.Publish)
|
||||||
adminRoutes.POST("/announcements/:id/archive", requirePerm("announcement:manage"), announcementHandler.Archive)
|
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)
|
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']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||||
ElStep: typeof import('element-plus/es')['ElStep']
|
ElStep: typeof import('element-plus/es')['ElStep']
|
||||||
ElSteps: typeof import('element-plus/es')['ElSteps']
|
ElSteps: typeof import('element-plus/es')['ElSteps']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
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`)
|
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> {
|
export async function deleteAnnouncement(id: number): Promise<void> {
|
||||||
await apiClient.delete(`/admin/announcements/${id}`)
|
await apiClient.delete(`/admin/announcements/${id}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
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 { Announcement } from '@/features/announcement'
|
||||||
import type {
|
import type {
|
||||||
CreateAnnouncementRequest,
|
CreateAnnouncementRequest,
|
||||||
UpdateAnnouncementRequest,
|
UpdateAnnouncementRequest,
|
||||||
} from '@/features/admin/api/adminAnnouncements'
|
} from '@/features/admin/api/adminAnnouncements'
|
||||||
|
import { uploadAdminFile } from '@/shared/api/files'
|
||||||
import { debugError } from '@/shared/utils/debug'
|
import { debugError } from '@/shared/utils/debug'
|
||||||
|
import { readError } from '@/shared/utils/error'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@@ -22,6 +25,17 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const formRef = ref<FormInstance>()
|
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>({
|
const formData = ref<CreateAnnouncementRequest>({
|
||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
@@ -51,6 +65,14 @@ const dialogTitle = computed(() => {
|
|||||||
return props.mode === 'create' ? '新建公告' : '编辑公告'
|
return props.mode === 'create' ? '新建公告' : '编辑公告'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const imageSettingsPreviewStyle = computed(() => {
|
||||||
|
if (!imageSettingsForm.value.useCustomWidth) return {}
|
||||||
|
return {
|
||||||
|
width: `${imageSettingsForm.value.width}px`,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.visible,
|
() => props.visible,
|
||||||
val => {
|
val => {
|
||||||
@@ -93,6 +115,153 @@ async function handleSave() {
|
|||||||
debugError('表单验证失败', error)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -122,12 +291,32 @@ async function handleSave() {
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="公告内容" prop="content">
|
<el-form-item label="公告内容" prop="content">
|
||||||
<el-input
|
<div class="content-editor">
|
||||||
v-model="formData.content"
|
<div class="content-toolbar">
|
||||||
type="textarea"
|
<el-button size="small" :loading="uploadingImage" @click="triggerImageUpload">
|
||||||
:rows="12"
|
<el-icon><Picture /></el-icon>
|
||||||
placeholder="支持 Markdown 或 HTML 格式,可以使用标题、列表、链接等"
|
插入图片
|
||||||
/>
|
</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>
|
||||||
|
|
||||||
<el-form-item label="优先级">
|
<el-form-item label="优先级">
|
||||||
@@ -153,9 +342,106 @@ async function handleSave() {
|
|||||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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) {
|
:deep(.el-textarea__inner) {
|
||||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
updateAnnouncement,
|
updateAnnouncement,
|
||||||
publishAnnouncement,
|
publishAnnouncement,
|
||||||
archiveAnnouncement,
|
archiveAnnouncement,
|
||||||
|
unarchiveAnnouncement,
|
||||||
deleteAnnouncement,
|
deleteAnnouncement,
|
||||||
type CreateAnnouncementRequest,
|
type CreateAnnouncementRequest,
|
||||||
type UpdateAnnouncementRequest,
|
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) {
|
async function handleDelete(item: Announcement) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('确认要删除该公告吗?此操作不可恢复。', '警告', {
|
await ElMessageBox.confirm('确认要删除该公告吗?此操作不可恢复。', '警告', {
|
||||||
@@ -314,6 +332,16 @@ function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
|
|||||||
>
|
>
|
||||||
归档
|
归档
|
||||||
</el-button>
|
</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
|
<el-button
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
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,
|
Warning,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { fetchAnnouncementDetail, type Announcement } from '@/features/announcement'
|
import { fetchAnnouncementDetail, type Announcement } from '@/features/announcement'
|
||||||
|
import { renderAnnouncementMarkdown } from '@/features/announcement/utils/markdown'
|
||||||
import { debugError } from '@/shared/utils/debug'
|
import { debugError } from '@/shared/utils/debug'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
import { marked } from 'marked'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const announcement = ref<Announcement | null>(null)
|
const announcement = ref<Announcement | null>(null)
|
||||||
|
|
||||||
// 配置 marked 选项
|
|
||||||
marked.setOptions({
|
|
||||||
breaks: true, // 支持换行
|
|
||||||
gfm: true, // GitHub Flavored Markdown
|
|
||||||
})
|
|
||||||
|
|
||||||
// 计算属性:将 Markdown 转换为 HTML
|
// 计算属性:将 Markdown 转换为 HTML
|
||||||
const renderedContent = computed(() => {
|
const renderedContent = computed(() => {
|
||||||
if (!announcement.value?.content) return ''
|
if (!announcement.value?.content) return ''
|
||||||
return marked.parse(announcement.value.content)
|
return renderAnnouncementMarkdown(announcement.value.content)
|
||||||
})
|
})
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
import { marked } from 'marked'
|
|
||||||
import { fetchAnnouncementDetail, type Announcement } from '@/features/announcement'
|
import { fetchAnnouncementDetail, type Announcement } from '@/features/announcement'
|
||||||
|
import { renderAnnouncementMarkdown } from '@/features/announcement/utils/markdown'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -11,11 +11,6 @@ const router = useRouter()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const announcement = ref<Announcement | null>(null)
|
const announcement = ref<Announcement | null>(null)
|
||||||
|
|
||||||
marked.setOptions({
|
|
||||||
breaks: true,
|
|
||||||
gfm: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ value: 'notice', label: '通知公告' },
|
{ value: 'notice', label: '通知公告' },
|
||||||
{ value: 'tutorial', label: '使用教程' },
|
{ value: 'tutorial', label: '使用教程' },
|
||||||
@@ -25,7 +20,7 @@ const categories = [
|
|||||||
|
|
||||||
const renderedContent = computed(() => {
|
const renderedContent = computed(() => {
|
||||||
if (!announcement.value?.content) return ''
|
if (!announcement.value?.content) return ''
|
||||||
return marked.parse(announcement.value.content)
|
return renderAnnouncementMarkdown(announcement.value.content)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(loadAnnouncement)
|
onMounted(loadAnnouncement)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
|
const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
|
||||||
avatar: 512,
|
avatar: 512,
|
||||||
chat: 1280,
|
chat: 1280,
|
||||||
|
announcement: 1600,
|
||||||
'home-banner': 1920,
|
'home-banner': 1920,
|
||||||
listing: 1920,
|
listing: 1920,
|
||||||
dispute: 1920,
|
dispute: 1920,
|
||||||
@@ -11,6 +12,7 @@ const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
|
|||||||
const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
|
const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
|
||||||
avatar: 0.82,
|
avatar: 0.82,
|
||||||
chat: 0.8,
|
chat: 0.8,
|
||||||
|
announcement: 0.84,
|
||||||
'home-banner': 0.84,
|
'home-banner': 0.84,
|
||||||
listing: 0.84,
|
listing: 0.84,
|
||||||
dispute: 0.86,
|
dispute: 0.86,
|
||||||
|
|||||||
Reference in New Issue
Block a user