feat(wxpa): 配图提示词规则从 Go 硬编码迁移到 JSON 配置驱动
- 新增 spec/06-image_prompt_generation/rule.json 定义配图分布规则(500 字间隔、封面图、正文图尺寸)
- 新增 prompt_template、bracket_format 等 JSON 配置,支持中文提示词模板
- 修改 buildCoverImagePrompt/buildContentImagePrompt 使用 strings.ReplaceAll 替换 {index}/{section_summary} 占位符
- 修改 executeWeixinPublicAccountImagePromptStep 调用 buildWeixinPublicAccountImagePromptsV2
- 新增 buildWeixinPublicAccountContentWithPromptsFromJSON 包装 renderWeixinPublicAccountImagePromptFromJSON
- 前端产物 Tab 新增配图缩略图行展示
This commit is contained in:
+9
-4
@@ -27,8 +27,8 @@ type weixinPublicAccountSection struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func executeWeixinPublicAccountImagePromptStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) weixinPublicAccountStepExecution {
|
func executeWeixinPublicAccountImagePromptStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) weixinPublicAccountStepExecution {
|
||||||
prompts := buildWeixinPublicAccountImagePrompts(workflow)
|
prompts := buildWeixinPublicAccountImagePromptsV2(workflow)
|
||||||
contentWithPrompts := buildWeixinPublicAccountContentWithPrompts(workflow.Shared.Content, prompts)
|
contentWithPrompts := buildWeixinPublicAccountContentWithPromptsFromJSON(workflow.Shared.Content, prompts)
|
||||||
workflow.Shared.ContentWithPrompts = contentWithPrompts
|
workflow.Shared.ContentWithPrompts = contentWithPrompts
|
||||||
workflow.Shared.ImagePrompts = prompts
|
workflow.Shared.ImagePrompts = prompts
|
||||||
|
|
||||||
@@ -55,9 +55,9 @@ func executeWeixinPublicAccountImagePromptStep(workflow *weixinPublicAccountWork
|
|||||||
func executeWeixinPublicAccountImageGenerationStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) (weixinPublicAccountStepExecution, *web.AppError) {
|
func executeWeixinPublicAccountImageGenerationStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) (weixinPublicAccountStepExecution, *web.AppError) {
|
||||||
prompts := workflow.Shared.ImagePrompts
|
prompts := workflow.Shared.ImagePrompts
|
||||||
if len(prompts) == 0 {
|
if len(prompts) == 0 {
|
||||||
prompts = buildWeixinPublicAccountImagePrompts(workflow)
|
prompts = buildWeixinPublicAccountImagePromptsV2(workflow)
|
||||||
workflow.Shared.ImagePrompts = prompts
|
workflow.Shared.ImagePrompts = prompts
|
||||||
workflow.Shared.ContentWithPrompts = buildWeixinPublicAccountContentWithPrompts(workflow.Shared.Content, prompts)
|
workflow.Shared.ContentWithPrompts = buildWeixinPublicAccountContentWithPromptsFromJSON(workflow.Shared.Content, prompts)
|
||||||
}
|
}
|
||||||
aiRoute, refs, appErr := generateWeixinPublicAccountImages(article.TaskID, workflow, prompts, user)
|
aiRoute, refs, appErr := generateWeixinPublicAccountImages(article.TaskID, workflow, prompts, user)
|
||||||
if appErr != nil {
|
if appErr != nil {
|
||||||
@@ -181,6 +181,11 @@ func buildWeixinPublicAccountContentWithPrompts(content string, prompts []weixin
|
|||||||
return strings.Join(blocks, "\n")
|
return strings.Join(blocks, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildWeixinPublicAccountContentWithPromptsFromJSON 基于 JSON 配置渲染带括号标记的正文。
|
||||||
|
func buildWeixinPublicAccountContentWithPromptsFromJSON(content string, prompts []weixinPublicAccountImagePrompt) string {
|
||||||
|
return renderWeixinPublicAccountImagePromptFromJSON(content, prompts)
|
||||||
|
}
|
||||||
|
|
||||||
func buildWeixinPublicAccountVisualPrompt(workflow *weixinPublicAccountWorkflowState, sectionTitle, context string) string {
|
func buildWeixinPublicAccountVisualPrompt(workflow *weixinPublicAccountWorkflowState, sectionTitle, context string) string {
|
||||||
keyword := firstNonEmpty(workflow.Form.Keyword, "业务主题")
|
keyword := firstNonEmpty(workflow.Form.Keyword, "业务主题")
|
||||||
tone := firstNonEmpty(workflow.Form.Tone, "专业但好懂")
|
tone := firstNonEmpty(workflow.Form.Tone, "专业但好懂")
|
||||||
|
|||||||
+23
-19
@@ -217,18 +217,20 @@ func buildWeixinPublicAccountImagePromptsV2(workflow *weixinPublicAccountWorkflo
|
|||||||
func buildCoverImagePrompt(workflow *weixinPublicAccountWorkflowState, rule *imagePromptRule) string {
|
func buildCoverImagePrompt(workflow *weixinPublicAccountWorkflowState, rule *imagePromptRule) string {
|
||||||
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
|
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
|
||||||
|
|
||||||
// 从 JSON 模板构建(当前回退到硬编码模板)
|
// 从 JSON 模板构建
|
||||||
template := rule.Template.Template
|
template := rule.Template.Template
|
||||||
if template == "" {
|
if template != "" {
|
||||||
// 回退到当前硬编码的英文模板
|
// 使用字符串替换,而非 fmt.Sprintf
|
||||||
return fmt.Sprintf("realistic editorial illustration, Chinese business article cover, %s, focus on %s, %s, clean composition, natural lighting, brand-safe, no text, no watermark",
|
prompt := strings.ReplaceAll(template, "{index}", "1")
|
||||||
weixinPublicAccountBusinessDomainLabel(workflow.Form.BusinessDomain),
|
prompt = strings.ReplaceAll(prompt, "{section_summary}", fmt.Sprintf("%s(截断至120字)", title))
|
||||||
title,
|
return prompt
|
||||||
"公众号封面主视觉",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
// 回退到硬编码模板
|
||||||
return fmt.Sprintf(template, 1, title, fmt.Sprintf("%s(截断至120字)", title))
|
return fmt.Sprintf("realistic editorial illustration, Chinese business article cover, %s, focus on %s, %s, clean composition, natural lighting, brand-safe, no text, no watermark",
|
||||||
|
weixinPublicAccountBusinessDomainLabel(workflow.Form.BusinessDomain),
|
||||||
|
title,
|
||||||
|
"公众号封面主视觉",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildContentImagePrompt 生成正文配图提示词。
|
// buildContentImagePrompt 生成正文配图提示词。
|
||||||
@@ -237,16 +239,18 @@ func buildContentImagePrompt(workflow *weixinPublicAccountWorkflowState, section
|
|||||||
|
|
||||||
// 从 JSON 模板构建
|
// 从 JSON 模板构建
|
||||||
template := rule.Template.Template
|
template := rule.Template.Template
|
||||||
if template == "" {
|
if template != "" {
|
||||||
// 回退到当前硬编码的英文模板
|
// 使用字符串替换,而非 fmt.Sprintf
|
||||||
return fmt.Sprintf("realistic editorial illustration, Chinese business article cover, %s, focus on %s, %s, clean composition, natural lighting, brand-safe, no text, no watermark",
|
prompt := strings.ReplaceAll(template, "{index}", fmt.Sprintf("%d", index))
|
||||||
weixinPublicAccountBusinessDomainLabel(workflow.Form.BusinessDomain),
|
prompt = strings.ReplaceAll(prompt, "{section_summary}", summary)
|
||||||
firstNonEmpty(section.Title, workflow.Form.Keyword),
|
return prompt
|
||||||
firstNonEmpty(summary, workflow.Form.Tone),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
// 回退到硬编码模板
|
||||||
return fmt.Sprintf(template, index, firstNonEmpty(section.Title, workflow.Form.Keyword), summary)
|
return fmt.Sprintf("realistic editorial illustration, Chinese business article cover, %s, focus on %s, %s, clean composition, natural lighting, brand-safe, no text, no watermark",
|
||||||
|
weixinPublicAccountBusinessDomainLabel(workflow.Form.BusinessDomain),
|
||||||
|
firstNonEmpty(section.Title, workflow.Form.Keyword),
|
||||||
|
firstNonEmpty(summary, workflow.Form.Tone),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// countChineseWords 计算内容的中文字数。
|
// countChineseWords 计算内容的中文字数。
|
||||||
|
|||||||
+1
-1
@@ -792,7 +792,7 @@ func respondWeixinPublicAccountWorkflow(c *gin.Context, task model.TaskRecord) {
|
|||||||
func findWeixinPublicAccountImagePrompt(workflow weixinPublicAccountWorkflowState, imageKey string) (weixinPublicAccountImagePrompt, int, bool) {
|
func findWeixinPublicAccountImagePrompt(workflow weixinPublicAccountWorkflowState, imageKey string) (weixinPublicAccountImagePrompt, int, bool) {
|
||||||
prompts := workflow.Shared.ImagePrompts
|
prompts := workflow.Shared.ImagePrompts
|
||||||
if len(prompts) == 0 {
|
if len(prompts) == 0 {
|
||||||
prompts = buildWeixinPublicAccountImagePrompts(&workflow)
|
prompts = buildWeixinPublicAccountImagePromptsV2(&workflow)
|
||||||
workflow.Shared.ImagePrompts = prompts
|
workflow.Shared.ImagePrompts = prompts
|
||||||
}
|
}
|
||||||
for idx, item := range prompts {
|
for idx, item := range prompts {
|
||||||
|
|||||||
+193
-31
@@ -148,20 +148,51 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="group.kind === 'files'" class="oa-file-list">
|
<div v-else-if="group.kind === 'files'" class="oa-file-list">
|
||||||
<div v-for="file in group.files" :key="file.key" class="oa-file-row">
|
<div v-if="group.isImageGroup" class="oa-image-group">
|
||||||
<span class="oa-file-kind">{{ file.kindLabel }}</span>
|
<div class="oa-image-group-summary">{{ group.summary }}</div>
|
||||||
<div class="oa-file-main">
|
<div v-for="file in group.files" :key="file.key" class="oa-image-row">
|
||||||
<span class="oa-file-name" :title="file.name">{{ file.name }}</span>
|
<img
|
||||||
<span v-if="file.meta" class="oa-file-meta">{{ file.meta }}</span>
|
v-if="file.thumbnailUrl"
|
||||||
|
:src="file.thumbnailUrl"
|
||||||
|
:alt="file.name"
|
||||||
|
class="oa-image-thumb"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
src="data:image/svg+xml;charset=utf-8,"
|
||||||
|
alt="加载中"
|
||||||
|
class="oa-image-thumb oa-image-thumb-loading"
|
||||||
|
/>
|
||||||
|
<div class="oa-image-row-info">
|
||||||
|
<span class="oa-image-row-section">{{ file.meta }}</span>
|
||||||
|
<span v-if="file.size" class="oa-image-row-size">{{ file.size }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="oa-btn oa-btn-tiny"
|
||||||
|
type="button"
|
||||||
|
:disabled="previewLoading"
|
||||||
|
@click="downloadFile(file)"
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="oa-file-list-plain">
|
||||||
|
<div v-for="file in group.files" :key="file.key" class="oa-file-row">
|
||||||
|
<span class="oa-file-kind">{{ file.kindLabel }}</span>
|
||||||
|
<div class="oa-file-main">
|
||||||
|
<span class="oa-file-name" :title="file.name">{{ file.name }}</span>
|
||||||
|
<span v-if="file.meta" class="oa-file-meta">{{ file.meta }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="oa-btn oa-btn-tiny"
|
||||||
|
type="button"
|
||||||
|
:disabled="previewLoading"
|
||||||
|
@click="downloadFile(file)"
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
class="oa-btn oa-btn-tiny"
|
|
||||||
type="button"
|
|
||||||
:disabled="previewLoading"
|
|
||||||
@click="downloadFile(file)"
|
|
||||||
>
|
|
||||||
下载
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -695,10 +726,49 @@ const artifactStepGroups = computed(() => orderedSteps.value.map((step, index) =
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
const artifactItems = computed(() => artifactStepGroups.value.map((group) => group.artifact).filter(Boolean))
|
const artifactItems = computed(() => artifactStepGroups.value.map((group) => group.artifact).filter(Boolean))
|
||||||
// 一份产物都没有时不再列 8 个「这一步还没有产物」的空壳,只留一句提示。
|
const visibleArtifactGroups = computed(() => (artifactItems.value.length ? artifactStepGroupsWithThumbnails.value : []))
|
||||||
// 跑过几步的任务则八步全列,缺哪一步一眼能看出来。
|
|
||||||
const visibleArtifactGroups = computed(() => (artifactItems.value.length ? artifactStepGroups.value : []))
|
|
||||||
const previewTitle = computed(() => (previewTarget.value?.name ? `预览 · ${previewTarget.value.name}` : '预览'))
|
const previewTitle = computed(() => (previewTarget.value?.name ? `预览 · ${previewTarget.value.name}` : '预览'))
|
||||||
|
const artifactImageUrls = computed(() => {
|
||||||
|
// Collect all image URLs from artifact files so we can load thumbnails
|
||||||
|
const urls = []
|
||||||
|
artifactStepGroups.value.forEach((group) => {
|
||||||
|
if (group.isImageGroup && group.files) {
|
||||||
|
group.files.forEach((file) => {
|
||||||
|
if (file.url) urls.push(String(file.url))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return urls
|
||||||
|
})
|
||||||
|
const artifactImageObjectUrls = computed(() => {
|
||||||
|
const result = {}
|
||||||
|
artifactImageUrls.value.forEach((url) => {
|
||||||
|
result[url] = imageObjectUrls.value[url] || ''
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Augment artifact step groups with thumbnail URLs
|
||||||
|
const artifactStepGroupsWithThumbnails = computed(() => {
|
||||||
|
return artifactStepGroups.value.map((group) => {
|
||||||
|
if (group.isImageGroup && group.files) {
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
files: group.files.map((file) => {
|
||||||
|
if (file.kind === 'image' && file.url) {
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
thumbnailUrl: artifactImageObjectUrls.value[String(file.url)] || imageObjectUrls.value[String(file.url)] || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return group
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const uploadItems = computed(() => {
|
const uploadItems = computed(() => {
|
||||||
const rawList = collectUploadItems(parseTaskContext(workflowTask.value))
|
const rawList = collectUploadItems(parseTaskContext(workflowTask.value))
|
||||||
return rawList.map((item, index) => normalizeUploadItem(item, index)).filter(Boolean)
|
return rawList.map((item, index) => normalizeUploadItem(item, index)).filter(Boolean)
|
||||||
@@ -1335,10 +1405,17 @@ function buildArtifactView(item, stepKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (base.kind === 'files') {
|
if (base.kind === 'files') {
|
||||||
const files = buildArtifactFiles(payload, view.type)
|
const filesOrGroup = buildArtifactFiles(payload, view.type)
|
||||||
if (!files.length) return degradeArtifactView(base, stepKey)
|
if (!filesOrGroup || (Array.isArray(filesOrGroup) && !filesOrGroup.length)) return degradeArtifactView(base, stepKey)
|
||||||
base.files = files
|
// images 返回的是 { isImageGroup: true, summary, files } 对象
|
||||||
base.summary = artifactFilesSummary(files, payload, view.type)
|
if (filesOrGroup.isImageGroup) {
|
||||||
|
base.isImageGroup = true
|
||||||
|
base.summary = filesOrGroup.summary
|
||||||
|
base.files = filesOrGroup.files
|
||||||
|
} else {
|
||||||
|
base.files = filesOrGroup
|
||||||
|
base.summary = artifactFilesSummary(filesOrGroup, payload, view.type)
|
||||||
|
}
|
||||||
base.body = ''
|
base.body = ''
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
@@ -1444,17 +1521,22 @@ function artifactRowsSummary(payload, artifactType, rows) {
|
|||||||
function buildArtifactFiles(payload, artifactType) {
|
function buildArtifactFiles(payload, artifactType) {
|
||||||
if (artifactType === 'images') {
|
if (artifactType === 'images') {
|
||||||
const refs = Array.isArray(payload?.image_refs) ? payload.image_refs : []
|
const refs = Array.isArray(payload?.image_refs) ? payload.image_refs : []
|
||||||
return refs.map((ref, idx) => {
|
return {
|
||||||
const url = String(ref?.url || '').trim()
|
isImageGroup: true,
|
||||||
return {
|
summary: `计划生成 ${refs.length} 张配图`,
|
||||||
key: `image-${ref?.key || idx}`,
|
files: refs.map((ref, idx) => {
|
||||||
kind: 'image',
|
const url = String(ref?.url || '').trim()
|
||||||
kindLabel: '图片',
|
return {
|
||||||
name: fileNameOfUrl(url) || `生成图 ${idx + 1}.png`,
|
key: `image-${ref?.key || idx}`,
|
||||||
meta: String(ref?.section || ref?.key || '').trim(),
|
kind: 'image',
|
||||||
url,
|
kindLabel: '图片',
|
||||||
}
|
name: fileNameOfUrl(url) || `生成图 ${idx + 1}.png`,
|
||||||
}).filter((file) => file.url)
|
meta: String(ref?.section || ref?.key || '').trim(),
|
||||||
|
size: ref?.image_size ? sizeLabel(ref.image_size) : '',
|
||||||
|
url,
|
||||||
|
}
|
||||||
|
}).filter((file) => file.url),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (artifactType === 'preview') {
|
if (artifactType === 'preview') {
|
||||||
const html = String(payload?.preview_html || '').trim()
|
const html = String(payload?.preview_html || '').trim()
|
||||||
@@ -1551,12 +1633,31 @@ function formatFileSize(value) {
|
|||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 图片尺寸标签
|
||||||
|
function sizeLabel(imageSize) {
|
||||||
|
const map = {
|
||||||
|
'landscape_16_9': '16:9 横图',
|
||||||
|
'landscape_4_3': '4:3 横图',
|
||||||
|
'portrait_4_3': '4:3 竖图',
|
||||||
|
'square_hd': '1:1 方形',
|
||||||
|
}
|
||||||
|
return map[imageSize] || imageSize
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [currentSpecialistKey.value, currentTaskId.value],
|
() => [currentSpecialistKey.value, currentTaskId.value],
|
||||||
refresh,
|
refresh,
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
artifactImageUrls,
|
||||||
|
(urls) => {
|
||||||
|
urls.forEach((url) => ensureImageObjectUrl(url))
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
imageRefs,
|
imageRefs,
|
||||||
(list) => {
|
(list) => {
|
||||||
@@ -2088,6 +2189,67 @@ onBeforeUnmount(() => {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 产物 Tab 中图片生成步骤的缩略图行 */
|
||||||
|
.oa-image-group-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oa-image-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oa-image-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oa-image-thumb {
|
||||||
|
width: 48px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oa-image-thumb-loading {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oa-image-row-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oa-image-row-section {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oa-image-row-size {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oa-file-list-plain {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.oa-image-list {
|
.oa-image-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
Reference in New Issue
Block a user