feat(media-supply): add image upload functionality and enhance HTML handling in article processing
This commit is contained in:
@@ -21,7 +21,7 @@ import { computed, reactive, ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import ArticleEditorCanvas from '@/components/ArticleEditorCanvas.vue'
|
import ArticleEditorCanvas from '@/components/ArticleEditorCanvas.vue'
|
||||||
import { articlesApi, mediaSupplyApi } from '@/lib/api'
|
import { articlesApi, imagesApi, mediaSupplyApi } from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
clearMediaSupplySubmitSelection,
|
clearMediaSupplySubmitSelection,
|
||||||
loadMediaSupplySubmitSelection,
|
loadMediaSupplySubmitSelection,
|
||||||
@@ -194,6 +194,11 @@ async function submitOrder(): Promise<void> {
|
|||||||
await createOrderMutation.mutateAsync()
|
await createOrderMutation.mutateAsync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadEditorImage(file: File): Promise<string> {
|
||||||
|
const result = await imagesApi.upload(file)
|
||||||
|
return result.url
|
||||||
|
}
|
||||||
|
|
||||||
function cancelSubmit(): void {
|
function cancelSubmit(): void {
|
||||||
void router.push({ name: 'media-supply-resources' })
|
void router.push({ name: 'media-supply-resources' })
|
||||||
}
|
}
|
||||||
@@ -377,7 +382,7 @@ function stripLeadingTitleHeading(titleValue: string, markdownValue: string): st
|
|||||||
v-model="form.content"
|
v-model="form.content"
|
||||||
:article-id="null"
|
:article-id="null"
|
||||||
:ai-optimize-enabled="false"
|
:ai-optimize-enabled="false"
|
||||||
:upload-image="undefined"
|
:upload-image="uploadEditorImage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -292,8 +292,51 @@ func TestMeijiequanCreateOrderFormMatchesUpstreamShape(t *testing.T) {
|
|||||||
|
|
||||||
func TestMeijiequanArticleHTMLKeepsExistingHTML(t *testing.T) {
|
func TestMeijiequanArticleHTMLKeepsExistingHTML(t *testing.T) {
|
||||||
input := `<p><img src="http://example.test/a.png" title="x" _src="http://example.test/a.png" alt="a"></p><p>正文</p>`
|
input := `<p><img src="http://example.test/a.png" title="x" _src="http://example.test/a.png" alt="a"></p><p>正文</p>`
|
||||||
if got := meijiequanArticleHTML(input); got != input {
|
got := meijiequanArticleHTML(input)
|
||||||
t.Fatalf("expected html passthrough, got %s", got)
|
if !strings.Contains(got, `<p><img src="http://example.test/a.png"`) ||
|
||||||
|
!strings.Contains(got, `title="x"`) ||
|
||||||
|
!strings.Contains(got, `_src="http://example.test/a.png"`) ||
|
||||||
|
!strings.Contains(got, `alt="a"`) ||
|
||||||
|
!strings.Contains(got, `<p>正文</p>`) {
|
||||||
|
t.Fatalf("expected html image and paragraphs to be preserved, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeijiequanArticleHTMLConvertsMilkdownMixedMarkdown(t *testing.T) {
|
||||||
|
input := strings.Join([]string{
|
||||||
|
"# 标题",
|
||||||
|
"",
|
||||||
|
`<p class="article-editor-image article-editor-image--center" align="center"><img src="http://example.test/a.webp" alt="图注" title="图注" data-asset-id="88" /></p>`,
|
||||||
|
"",
|
||||||
|
"## 小标题",
|
||||||
|
"",
|
||||||
|
"- 第一条",
|
||||||
|
"- **第二条**",
|
||||||
|
"",
|
||||||
|
"| 维度 | 说明 |",
|
||||||
|
"| --- | --- |",
|
||||||
|
"| 环保 | ENF |",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
got := meijiequanArticleHTML(input)
|
||||||
|
if !strings.Contains(got, "<h1>标题</h1>") ||
|
||||||
|
!strings.Contains(got, `<img src="http://example.test/a.webp"`) ||
|
||||||
|
!strings.Contains(got, `_src="http://example.test/a.webp"`) ||
|
||||||
|
strings.Contains(got, "data-asset-id") ||
|
||||||
|
!strings.Contains(got, "<h2>小标题</h2>") ||
|
||||||
|
!strings.Contains(got, "<li>第一条</li>") ||
|
||||||
|
!strings.Contains(got, "<strong>第二条</strong>") ||
|
||||||
|
!strings.Contains(got, "<table>") ||
|
||||||
|
!strings.Contains(got, "<td>ENF</td>") {
|
||||||
|
t.Fatalf("unexpected mixed markdown html: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeijiequanArticleHTMLBackfillsImageSrcFromOriginalSrc(t *testing.T) {
|
||||||
|
got := meijiequanArticleHTML(`<p><img _src="http://example.test/original.png" alt="a"></p>`)
|
||||||
|
if !strings.Contains(got, `src="http://example.test/original.png"`) ||
|
||||||
|
!strings.Contains(got, `_src="http://example.test/original.png"`) {
|
||||||
|
t.Fatalf("expected src and _src to be normalized, got %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import (
|
|||||||
|
|
||||||
goredis "github.com/redis/go-redis/v9"
|
goredis "github.com/redis/go-redis/v9"
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/extension"
|
||||||
|
goldhtml "github.com/yuin/goldmark/renderer/html"
|
||||||
nethtml "golang.org/x/net/html"
|
nethtml "golang.org/x/net/html"
|
||||||
"golang.org/x/net/html/atom"
|
"golang.org/x/net/html/atom"
|
||||||
|
|
||||||
@@ -1153,28 +1155,16 @@ func meijiequanArticleHTML(content string) string {
|
|||||||
if source == "" {
|
if source == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if looksLikeHTML(source) {
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
return markdownToMeijiequanHTML(source)
|
return markdownToMeijiequanHTML(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
func looksLikeHTML(source string) bool {
|
|
||||||
lower := strings.ToLower(strings.TrimSpace(source))
|
|
||||||
return strings.Contains(lower, "<p") ||
|
|
||||||
strings.Contains(lower, "<h1") ||
|
|
||||||
strings.Contains(lower, "<h2") ||
|
|
||||||
strings.Contains(lower, "<h3") ||
|
|
||||||
strings.Contains(lower, "<img") ||
|
|
||||||
strings.Contains(lower, "<figure") ||
|
|
||||||
strings.Contains(lower, "<table") ||
|
|
||||||
strings.Contains(lower, "<ul") ||
|
|
||||||
strings.Contains(lower, "<ol")
|
|
||||||
}
|
|
||||||
|
|
||||||
func markdownToMeijiequanHTML(markdown string) string {
|
func markdownToMeijiequanHTML(markdown string) string {
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
if err := goldmark.Convert([]byte(markdown), &output); err != nil {
|
renderer := goldmark.New(
|
||||||
|
goldmark.WithExtensions(extension.GFM),
|
||||||
|
goldmark.WithRendererOptions(goldhtml.WithUnsafe()),
|
||||||
|
)
|
||||||
|
if err := renderer.Convert([]byte(markdown), &output); err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return addMeijiequanImageSrcAttributes(strings.TrimSpace(output.String()))
|
return addMeijiequanImageSrcAttributes(strings.TrimSpace(output.String()))
|
||||||
@@ -1211,24 +1201,57 @@ func addMeijiequanImageSrcAttribute(node *nethtml.Node) {
|
|||||||
}
|
}
|
||||||
if node.Type == nethtml.ElementNode && strings.EqualFold(node.Data, "img") {
|
if node.Type == nethtml.ElementNode && strings.EqualFold(node.Data, "img") {
|
||||||
src := ""
|
src := ""
|
||||||
hasOriginalSrc := false
|
originalSrc := ""
|
||||||
|
dataSrc := ""
|
||||||
|
attrs := make([]nethtml.Attribute, 0, len(node.Attr)+1)
|
||||||
for _, attr := range node.Attr {
|
for _, attr := range node.Attr {
|
||||||
switch strings.ToLower(attr.Key) {
|
switch strings.ToLower(strings.TrimSpace(attr.Key)) {
|
||||||
case "src":
|
case "src":
|
||||||
src = strings.TrimSpace(attr.Val)
|
src = strings.TrimSpace(attr.Val)
|
||||||
case "_src":
|
case "_src":
|
||||||
hasOriginalSrc = true
|
originalSrc = strings.TrimSpace(attr.Val)
|
||||||
|
case "data-src":
|
||||||
|
dataSrc = strings.TrimSpace(attr.Val)
|
||||||
|
case "data-asset-id":
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
attrs = append(attrs, attr)
|
||||||
|
}
|
||||||
|
if src == "" {
|
||||||
|
src = firstNonEmptyText(originalSrc, dataSrc)
|
||||||
|
}
|
||||||
|
if src != "" {
|
||||||
|
for idx := range attrs {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(attrs[idx].Key)) {
|
||||||
|
case "src":
|
||||||
|
attrs[idx].Val = src
|
||||||
|
case "_src":
|
||||||
|
attrs[idx].Val = src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !meijiequanHTMLAttrsHaveKey(attrs, "src") {
|
||||||
|
attrs = append(attrs, nethtml.Attribute{Key: "src", Val: src})
|
||||||
|
}
|
||||||
|
if !meijiequanHTMLAttrsHaveKey(attrs, "_src") {
|
||||||
|
attrs = append(attrs, nethtml.Attribute{Key: "_src", Val: src})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if src != "" && !hasOriginalSrc {
|
node.Attr = attrs
|
||||||
node.Attr = append(node.Attr, nethtml.Attribute{Key: "_src", Val: src})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||||
addMeijiequanImageSrcAttribute(child)
|
addMeijiequanImageSrcAttribute(child)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func meijiequanHTMLAttrsHaveKey(attrs []nethtml.Attribute, key string) bool {
|
||||||
|
for _, attr := range attrs {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(attr.Key), key) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (c *MeijiequanClient) httpClient(ctx context.Context) (*http.Client, string, error) {
|
func (c *MeijiequanClient) httpClient(ctx context.Context) (*http.Client, string, error) {
|
||||||
session, err := c.loadSession(ctx)
|
session, err := c.loadSession(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user