Compare commits
7 Commits
0f4310c345
...
9727077dfa
| Author | SHA1 | Date | |
|---|---|---|---|
| 9727077dfa | |||
| b345ee26e4 | |||
| 2436f50c1f | |||
| b18a562b32 | |||
| 5a613abc33 | |||
| dca36ed1f6 | |||
| 490c6c759d |
@@ -6,7 +6,7 @@
|
|||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0"
|
content="width=device-width, initial-scale=1.0"
|
||||||
/>
|
/>
|
||||||
<title>GEO 工具平台</title>
|
<title>省心推</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import enUS from "ant-design-vue/es/locale/en_US";
|
|
||||||
import zhCN from "ant-design-vue/es/locale/zh_CN";
|
import zhCN from "ant-design-vue/es/locale/zh_CN";
|
||||||
import { computed } from "vue";
|
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
|
|
||||||
import ImageUploadProgressOverlay from "@/components/ImageUploadProgressOverlay.vue";
|
import ImageUploadProgressOverlay from "@/components/ImageUploadProgressOverlay.vue";
|
||||||
|
|
||||||
const { locale } = useI18n();
|
|
||||||
const antLocale = computed(() => (locale.value === "en-US" ? enUS : zhCN));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-config-provider
|
<a-config-provider
|
||||||
:locale="antLocale"
|
:locale="zhCN"
|
||||||
:theme="{
|
:theme="{
|
||||||
token: {
|
token: {
|
||||||
colorPrimary: '#1677ff',
|
colorPrimary: '#1677ff',
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const deleteMutation = useMutation({
|
|||||||
const columns = computed<TableColumnsType<ArticleListItem>>(() => [
|
const columns = computed<TableColumnsType<ArticleListItem>>(() => [
|
||||||
{ title: t("custom.article.titleColumn"), dataIndex: "title", key: "title", width: 340 },
|
{ title: t("custom.article.titleColumn"), dataIndex: "title", key: "title", width: 340 },
|
||||||
{ title: t("custom.filters.promptRule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 160 },
|
{ title: t("custom.filters.promptRule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 160 },
|
||||||
{ title: t("custom.schedule.platform"), dataIndex: "platforms", key: "platforms", width: 180 },
|
{ title: t("custom.schedule.autoPublishPlatforms"), dataIndex: "auto_publish_platforms", key: "auto_publish_platforms", width: 180 },
|
||||||
{ title: t("common.generateStatus"), dataIndex: "generate_status", key: "generate_status", width: 128 },
|
{ title: t("common.generateStatus"), dataIndex: "generate_status", key: "generate_status", width: 128 },
|
||||||
{ title: t("common.publishStatus"), dataIndex: "publish_status", key: "publish_status", width: 128 },
|
{ title: t("common.publishStatus"), dataIndex: "publish_status", key: "publish_status", width: 128 },
|
||||||
{ title: t("common.wordCount"), dataIndex: "word_count", key: "word_count", width: 100 },
|
{ title: t("common.wordCount"), dataIndex: "word_count", key: "word_count", width: 100 },
|
||||||
@@ -319,8 +319,8 @@ onBeforeUnmount(() => {
|
|||||||
<template v-else-if="column.key === 'prompt_rule_name'">
|
<template v-else-if="column.key === 'prompt_rule_name'">
|
||||||
{{ record.prompt_rule_name || "--" }}
|
{{ record.prompt_rule_name || "--" }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'platforms'">
|
<template v-else-if="column.key === 'auto_publish_platforms'">
|
||||||
{{ formatPublishPlatformList(record.platforms) }}
|
{{ formatPublishPlatformList(record.auto_publish_platforms) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'generate_status'">
|
<template v-else-if="column.key === 'generate_status'">
|
||||||
<ArticleGenerateStatus :status="record.generate_status" />
|
<ArticleGenerateStatus :status="record.generate_status" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { PlusOutlined } from "@ant-design/icons-vue";
|
import { MinusCircleFilled, PlusOutlined } from "@ant-design/icons-vue";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
import { message } from "ant-design-vue";
|
import { message } from "ant-design-vue";
|
||||||
import type { PromptRuleSimple, ScheduleTask } from "@geo/shared-types";
|
import type { PromptRuleSimple, ScheduleTask } from "@geo/shared-types";
|
||||||
@@ -503,26 +503,28 @@ function padTime(value: string): string {
|
|||||||
{{ scheduleCoverRequired ? t("custom.task.scheduleCoverRequiredHint") : t("custom.task.scheduleCoverHint") }}
|
{{ scheduleCoverRequired ? t("custom.task.scheduleCoverRequiredHint") : t("custom.task.scheduleCoverHint") }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="effectiveScheduleCoverEnabled" class="generate-task-drawer__cover-row">
|
<div v-if="effectiveScheduleCoverEnabled" class="generate-task-drawer__cover-body">
|
||||||
<button type="button" class="generate-task-drawer__cover" @click="coverPickerOpen = true">
|
<div class="generate-task-drawer__cover-preview-wrap">
|
||||||
|
<button type="button" class="generate-task-drawer__cover-preview" @click="coverPickerOpen = true">
|
||||||
<template v-if="form.coverAssetUrl">
|
<template v-if="form.coverAssetUrl">
|
||||||
<img :src="form.coverAssetUrl" alt="cover preview" />
|
<img :src="form.coverAssetUrl" alt="cover preview" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="generate-task-drawer__cover-plus">+</span>
|
<span class="generate-task-drawer__cover-plus">+</span>
|
||||||
<strong>{{ t("custom.task.coverUpload") }}</strong>
|
<span>{{ t("custom.task.coverUpload") }}</span>
|
||||||
<small>{{ t("custom.task.scheduleCoverUploadHint") }}</small>
|
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="form.coverAssetUrl" class="generate-task-drawer__cover-meta">
|
<button
|
||||||
<strong>{{ form.coverFileName || t("custom.task.cover") }}</strong>
|
v-if="form.coverAssetUrl"
|
||||||
<a-button size="small" @click="coverPickerOpen = true">
|
type="button"
|
||||||
{{ t("article.editor.coverReplace") }}
|
class="generate-task-drawer__cover-remove"
|
||||||
</a-button>
|
:aria-label="t('article.editor.coverRemove')"
|
||||||
<a-button size="small" danger @click="handleRemoveScheduleCover">
|
:title="t('article.editor.coverRemove')"
|
||||||
{{ t("article.editor.coverRemove") }}
|
@click.stop="handleRemoveScheduleCover"
|
||||||
</a-button>
|
>
|
||||||
|
<MinusCircleFilled />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -806,8 +808,7 @@ function padTime(value: string): string {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-task-drawer__account-copy strong,
|
.generate-task-drawer__account-copy strong {
|
||||||
.generate-task-drawer__cover-meta strong {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -846,7 +847,7 @@ function padTime(value: string): string {
|
|||||||
.generate-task-drawer__cover-head {
|
.generate-task-drawer__cover-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,71 +876,72 @@ function padTime(value: string): string {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-task-drawer__cover-row {
|
.generate-task-drawer__cover-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
margin-top: 12px;
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-task-drawer__cover {
|
.generate-task-drawer__cover-preview-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 176px;
|
||||||
|
flex: 0 0 176px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-task-drawer__cover-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 228px;
|
width: 100%;
|
||||||
min-height: 172px;
|
min-height: 124px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px dashed #cfd9e8;
|
border: 1px dashed #d1d5db;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fbfcff;
|
background: #f9fafb;
|
||||||
color: #1f2937;
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-task-drawer__cover:hover {
|
.generate-task-drawer__cover-preview:hover {
|
||||||
border-color: #9db7ff;
|
border-color: #6366f1;
|
||||||
background: #f7faff;
|
background: #f5f3ff;
|
||||||
|
color: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-task-drawer__cover img {
|
.generate-task-drawer__cover-preview img {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 172px;
|
height: 124px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-task-drawer__cover strong {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.generate-task-drawer__cover small {
|
|
||||||
color: #667085;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.generate-task-drawer__cover-plus {
|
.generate-task-drawer__cover-plus {
|
||||||
display: inline-flex;
|
font-size: 20px;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #eef4ff;
|
|
||||||
color: #355dff;
|
|
||||||
font-size: 28px;
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-task-drawer__cover-meta {
|
.generate-task-drawer__cover-remove {
|
||||||
display: flex;
|
position: absolute;
|
||||||
min-width: 220px;
|
top: 8px;
|
||||||
max-width: 360px;
|
right: 8px;
|
||||||
flex-direction: column;
|
display: inline-flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(17, 24, 39, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-task-drawer__cover-remove:hover {
|
||||||
|
background: rgba(17, 24, 39, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.generate-task-drawer__time-wrapper {
|
.generate-task-drawer__time-wrapper {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const columns = computed<TableColumnsType<InstantTaskItem>>(() => [
|
|||||||
{ title: t("custom.schedule.rule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 180 },
|
{ title: t("custom.schedule.rule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 180 },
|
||||||
{ title: t("custom.instant.executionStatus"), dataIndex: "status", key: "status", width: 140 },
|
{ title: t("custom.instant.executionStatus"), dataIndex: "status", key: "status", width: 140 },
|
||||||
{ title: t("custom.instant.executionTime"), dataIndex: "execution_time", key: "execution_time", width: 170 },
|
{ title: t("custom.instant.executionTime"), dataIndex: "execution_time", key: "execution_time", width: 170 },
|
||||||
{ title: t("custom.schedule.platform"), dataIndex: "platforms", key: "platforms", width: 220 },
|
{ title: t("custom.schedule.autoPublishPlatforms"), dataIndex: "auto_publish_platforms", key: "auto_publish_platforms", width: 220 },
|
||||||
{ title: t("custom.task.generateCount"), dataIndex: "generate_count", key: "generate_count", width: 120 },
|
{ title: t("custom.task.generateCount"), dataIndex: "generate_count", key: "generate_count", width: 120 },
|
||||||
{ title: t("custom.instant.createdTime"), dataIndex: "created_at", key: "created_at", width: 170 },
|
{ title: t("custom.instant.createdTime"), dataIndex: "created_at", key: "created_at", width: 170 },
|
||||||
{ title: t("common.actions"), key: "actions", width: 88, fixed: "right", align: "right" },
|
{ title: t("common.actions"), key: "actions", width: 88, fixed: "right", align: "right" },
|
||||||
@@ -235,8 +235,8 @@ onBeforeUnmount(() => {
|
|||||||
<template v-else-if="column.key === 'execution_time'">
|
<template v-else-if="column.key === 'execution_time'">
|
||||||
{{ formatDateTime(record.execution_time) }}
|
{{ formatDateTime(record.execution_time) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'platforms'">
|
<template v-else-if="column.key === 'auto_publish_platforms'">
|
||||||
{{ formatPublishPlatformList(record.platforms) }}
|
{{ formatPublishPlatformList(record.auto_publish_platforms) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'generate_count'">
|
<template v-else-if="column.key === 'generate_count'">
|
||||||
{{ record.generate_count || 1 }}
|
{{ record.generate_count || 1 }}
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ import {
|
|||||||
publishStateLabel,
|
publishStateLabel,
|
||||||
type PublishAccountCard,
|
type PublishAccountCard,
|
||||||
} from "@/lib/publish-account-cards";
|
} from "@/lib/publish-account-cards";
|
||||||
import {
|
|
||||||
normalizePublishPlatformIds,
|
|
||||||
} from "@/lib/publish-platforms";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -93,10 +90,8 @@ watch(
|
|||||||
|
|
||||||
const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? []));
|
const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? []));
|
||||||
|
|
||||||
const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuery.data.value?.platforms ?? []));
|
|
||||||
|
|
||||||
const accountCards = computed<PublishAccountCard[]>(() =>
|
const accountCards = computed<PublishAccountCard[]>(() =>
|
||||||
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value, articlePlatformIds.value),
|
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value),
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -137,10 +132,7 @@ watchEffect(() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredPlatforms = new Set(articlePlatformIds.value);
|
selectedAccountIds.value = [];
|
||||||
selectedAccountIds.value = accountCards.value
|
|
||||||
.filter((card) => card.selectable && preferredPlatforms.has(card.platformId))
|
|
||||||
.map((card) => card.id);
|
|
||||||
selectionHydrated.value = true;
|
selectionHydrated.value = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,7 +229,6 @@ async function persistCoverIfNeeded(detail: ArticleDetail): Promise<ArticleDetai
|
|||||||
return articlesApi.update(detail.id, {
|
return articlesApi.update(detail.id, {
|
||||||
title: detail.title?.trim() || t("article.untitled"),
|
title: detail.title?.trim() || t("article.untitled"),
|
||||||
markdown_content: detail.markdown_content ?? "",
|
markdown_content: detail.markdown_content ?? "",
|
||||||
platforms: normalizePublishPlatformIds(detail.platforms ?? []),
|
|
||||||
cover_asset_url: nextUrl || null,
|
cover_asset_url: nextUrl || null,
|
||||||
cover_image_asset_id: coverImageAssetId.value,
|
cover_image_asset_id: coverImageAssetId.value,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ScheduleTaskModal from "@/components/ScheduleTaskModal.vue";
|
|||||||
import { promptRulesApi, schedulesApi } from "@/lib/api";
|
import { promptRulesApi, schedulesApi } from "@/lib/api";
|
||||||
import { formatCronExecutionTime, formatDateTime } from "@/lib/display";
|
import { formatCronExecutionTime, formatDateTime } from "@/lib/display";
|
||||||
import { formatError } from "@/lib/errors";
|
import { formatError } from "@/lib/errors";
|
||||||
|
import { formatPublishPlatformList } from "@/lib/publish-platforms";
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -84,7 +85,7 @@ const columns = computed<TableColumnsType<ScheduleTask>>(() => [
|
|||||||
{ title: t("custom.schedule.rule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 180 },
|
{ title: t("custom.schedule.rule"), dataIndex: "prompt_rule_name", key: "prompt_rule_name", width: 180 },
|
||||||
{ title: t("custom.instant.executionStatus"), dataIndex: "status", key: "status", width: 140 },
|
{ title: t("custom.instant.executionStatus"), dataIndex: "status", key: "status", width: 140 },
|
||||||
{ title: t("custom.instant.executionTime"), dataIndex: "cron_expr", key: "cron_expr", width: 170 },
|
{ title: t("custom.instant.executionTime"), dataIndex: "cron_expr", key: "cron_expr", width: 170 },
|
||||||
{ title: t("custom.schedule.autoPublish"), dataIndex: "auto_publish", key: "auto_publish", width: 160 },
|
{ title: t("custom.schedule.autoPublishPlatforms"), dataIndex: "auto_publish_platforms", key: "auto_publish_platforms", width: 200 },
|
||||||
{ title: t("custom.task.generateCount"), dataIndex: "generate_count", key: "generate_count", width: 120 },
|
{ title: t("custom.task.generateCount"), dataIndex: "generate_count", key: "generate_count", width: 120 },
|
||||||
{ title: t("custom.instant.createdTime"), dataIndex: "created_at", key: "created_at", width: 170 },
|
{ title: t("custom.instant.createdTime"), dataIndex: "created_at", key: "created_at", width: 170 },
|
||||||
{ title: t("common.actions"), key: "actions", width: 140, fixed: "right", align: "right" },
|
{ title: t("common.actions"), key: "actions", width: 140, fixed: "right", align: "right" },
|
||||||
@@ -222,11 +223,11 @@ function handleTableChange(nextPage: number, nextPageSize: number): void {
|
|||||||
<template v-else-if="column.key === 'cron_expr'">
|
<template v-else-if="column.key === 'cron_expr'">
|
||||||
{{ formatCronExecutionTime(record.cron_expr) }}
|
{{ formatCronExecutionTime(record.cron_expr) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'auto_publish'">
|
<template v-else-if="column.key === 'auto_publish_platforms'">
|
||||||
<a-tag :color="record.auto_publish ? 'blue' : 'default'">
|
<a-tag :color="record.auto_publish ? 'blue' : 'default'">
|
||||||
{{
|
{{
|
||||||
record.auto_publish
|
record.auto_publish
|
||||||
? t("custom.schedule.autoPublishAccountCount", { count: record.publish_account_ids?.length ?? 0 })
|
? formatPublishPlatformList(record.auto_publish_platforms)
|
||||||
: t("custom.schedule.manualPublish")
|
: t("custom.schedule.manualPublish")
|
||||||
}}
|
}}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
|
|||||||
@@ -1,44 +1,14 @@
|
|||||||
import { createI18n } from "vue-i18n";
|
import { createI18n } from "vue-i18n";
|
||||||
|
|
||||||
import enUS from "./messages/en-US";
|
|
||||||
import zhCN from "./messages/zh-CN";
|
import zhCN from "./messages/zh-CN";
|
||||||
|
|
||||||
export const LOCALE_STORAGE_KEY = "geo.admin.locale";
|
const APP_LOCALE = "zh-CN";
|
||||||
export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const;
|
|
||||||
|
|
||||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
|
||||||
|
|
||||||
function resolveLocale(): AppLocale {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return "zh-CN";
|
|
||||||
}
|
|
||||||
|
|
||||||
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
|
||||||
if (stored && SUPPORTED_LOCALES.includes(stored as AppLocale)) {
|
|
||||||
return stored as AppLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
const browserLocale = window.navigator.language;
|
|
||||||
if (browserLocale.startsWith("zh")) {
|
|
||||||
return "zh-CN";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "en-US";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const i18n = createI18n({
|
export const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: resolveLocale(),
|
locale: APP_LOCALE,
|
||||||
fallbackLocale: "en-US",
|
fallbackLocale: APP_LOCALE,
|
||||||
messages: {
|
messages: {
|
||||||
"zh-CN": zhCN,
|
"zh-CN": zhCN,
|
||||||
"en-US": enUS,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function setAppLocale(locale: AppLocale): void {
|
|
||||||
i18n.global.locale.value = locale;
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ const enUS = {
|
|||||||
backToQuestions: "Back to questions",
|
backToQuestions: "Back to questions",
|
||||||
citationRanking: "Citation Ranking",
|
citationRanking: "Citation Ranking",
|
||||||
citationRankingTitle: "Citation Ranking",
|
citationRankingTitle: "Citation Ranking",
|
||||||
citationRankingHint: "Attribute SaaS-published content by registrable domain across model citation sources.",
|
citationRankingHint: "Attribute SaaS-published content by published-link identity across model citation sources.",
|
||||||
citationWindow7: "Last 7 days",
|
citationWindow7: "Last 7 days",
|
||||||
citationWindow30: "Last 30 days",
|
citationWindow30: "Last 30 days",
|
||||||
citationWindowLabel: "Last {days} days",
|
citationWindowLabel: "Last {days} days",
|
||||||
@@ -1290,9 +1290,9 @@ const enUS = {
|
|||||||
name: "Task name",
|
name: "Task name",
|
||||||
rule: "Prompt rule",
|
rule: "Prompt rule",
|
||||||
cron: "Frequency",
|
cron: "Frequency",
|
||||||
|
autoPublishPlatforms: "Auto publish platforms",
|
||||||
autoPublish: "Auto publish",
|
autoPublish: "Auto publish",
|
||||||
manualPublish: "Manual publish",
|
manualPublish: "Manual publish",
|
||||||
autoPublishAccountCount: "Auto · {count} accounts",
|
|
||||||
nextRun: "Next run",
|
nextRun: "Next run",
|
||||||
startAt: "Start time",
|
startAt: "Start time",
|
||||||
endAt: "End time",
|
endAt: "End time",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const zhCN = {
|
const zhCN = {
|
||||||
app: {
|
app: {
|
||||||
name: "GEO工具平台",
|
name: "省心推",
|
||||||
loginIntro: "面向企业的品牌资产管理与内容生产协作系统",
|
loginIntro: "面向企业和个人的品牌资产管理与内容生产协作系统",
|
||||||
},
|
},
|
||||||
locale: {
|
locale: {
|
||||||
"zh-CN": "中文简体",
|
"zh-CN": "中文简体",
|
||||||
@@ -153,7 +153,7 @@ const zhCN = {
|
|||||||
route: {
|
route: {
|
||||||
login: {
|
login: {
|
||||||
title: "欢迎回来",
|
title: "欢迎回来",
|
||||||
description: "使用真实租户账号登录 GEO 工具平台。",
|
description: "登录省心推,开启品牌内容创作之旅",
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
title: "工作台",
|
title: "工作台",
|
||||||
@@ -433,7 +433,7 @@ const zhCN = {
|
|||||||
backToQuestions: "返回问题列表",
|
backToQuestions: "返回问题列表",
|
||||||
citationRanking: "Citation Ranking",
|
citationRanking: "Citation Ranking",
|
||||||
citationRankingTitle: "引用排行",
|
citationRankingTitle: "引用排行",
|
||||||
citationRankingHint: "按主体域名归因 SaaS 发文在模型引用来源中的出现次数与占比。",
|
citationRankingHint: "按发布外链主体归因 SaaS 发文在模型引用来源中的出现次数与占比。",
|
||||||
citationWindow7: "近 7 天",
|
citationWindow7: "近 7 天",
|
||||||
citationWindow30: "近 30 天",
|
citationWindow30: "近 30 天",
|
||||||
citationWindowLabel: "近 {days} 天",
|
citationWindowLabel: "近 {days} 天",
|
||||||
@@ -1300,9 +1300,9 @@ const zhCN = {
|
|||||||
name: "任务名称",
|
name: "任务名称",
|
||||||
rule: "关联规则",
|
rule: "关联规则",
|
||||||
cron: "执行频率",
|
cron: "执行频率",
|
||||||
|
autoPublishPlatforms: "自动发布平台",
|
||||||
autoPublish: "自动发布",
|
autoPublish: "自动发布",
|
||||||
manualPublish: "手动发布",
|
manualPublish: "手动发布",
|
||||||
autoPublishAccountCount: "自动 · {count} 个账号",
|
|
||||||
nextRun: "下次执行",
|
nextRun: "下次执行",
|
||||||
startAt: "开始时间",
|
startAt: "开始时间",
|
||||||
endAt: "结束时间",
|
endAt: "结束时间",
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
import { workspaceApi } from "@/lib/api";
|
import { workspaceApi } from "@/lib/api";
|
||||||
import { type AppLocale, setAppLocale } from "@/i18n";
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -86,22 +85,6 @@ type NavSection = {
|
|||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const localeOptions = computed(() => [
|
|
||||||
{ label: t("locale.zh-CN"), value: "zh-CN" },
|
|
||||||
{ label: t("locale.en-US"), value: "en-US" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const currentLocale = computed({
|
|
||||||
get: () => locale.value as AppLocale,
|
|
||||||
set: (value: AppLocale) => {
|
|
||||||
setAppLocale(value);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentLocaleLabel = computed(() => {
|
|
||||||
return localeOptions.value.find((opt) => opt.value === currentLocale.value)?.label ?? "Language";
|
|
||||||
});
|
|
||||||
|
|
||||||
const navSections = computed<NavSection[]>(() => {
|
const navSections = computed<NavSection[]>(() => {
|
||||||
const base = [
|
const base = [
|
||||||
{
|
{
|
||||||
@@ -220,10 +203,6 @@ function go(path: string): void {
|
|||||||
void router.push(path);
|
void router.push(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLocaleChange({ key }: { key: any }): void {
|
|
||||||
currentLocale.value = key as AppLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout(): Promise<void> {
|
async function handleLogout(): Promise<void> {
|
||||||
await authStore.logout();
|
await authStore.logout();
|
||||||
await router.replace("/login");
|
await router.replace("/login");
|
||||||
@@ -298,22 +277,6 @@ async function handleLogout(): Promise<void> {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="admin-header-actions">
|
<div class="admin-header-actions">
|
||||||
<div v-if="!isMembershipBlocked" class="admin-locale-switch">
|
|
||||||
<a-dropdown placement="bottomRight" :trigger="['click', 'hover']">
|
|
||||||
<div class="locale-dropdown-trigger">
|
|
||||||
<GlobalOutlined class="locale-icon" />
|
|
||||||
<span>{{ currentLocaleLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<template #overlay>
|
|
||||||
<a-menu :selected-keys="[currentLocale]" @click="handleLocaleChange">
|
|
||||||
<a-menu-item v-for="opt in localeOptions" :key="opt.value">
|
|
||||||
{{ opt.label }}
|
|
||||||
</a-menu-item>
|
|
||||||
</a-menu>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quota Indicator -->
|
<!-- Quota Indicator -->
|
||||||
<div v-if="!isMembershipBlocked" class="quota-pill">
|
<div v-if="!isMembershipBlocked" class="quota-pill">
|
||||||
<a-tag color="blue" :bordered="false" class="quota-pill-tag">{{ quotaSummary?.plan_name || t("shell.planFallback") }}</a-tag>
|
<a-tag color="blue" :bordered="false" class="quota-pill-tag">{{ quotaSummary?.plan_name || t("shell.planFallback") }}</a-tag>
|
||||||
@@ -390,6 +353,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
|
|
||||||
.admin-main-layout {
|
.admin-main-layout {
|
||||||
margin-left: 250px;
|
margin-left: 250px;
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-main-layout--blocked {
|
.admin-main-layout--blocked {
|
||||||
@@ -424,7 +388,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
z-index: 1;
|
z-index: 100;
|
||||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,37 +451,6 @@ async function handleLogout(): Promise<void> {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-locale-switch {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-locale-select {
|
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-dropdown-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #141414;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-dropdown-trigger:hover {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.locale-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #595959;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quota-pill {
|
.quota-pill {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -620,6 +553,8 @@ async function handleLogout(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-content {
|
.admin-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
margin: 24px;
|
margin: 24px;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1078,6 +1078,10 @@ export const monitoringApi = {
|
|||||||
},
|
},
|
||||||
citationSummary(params: {
|
citationSummary(params: {
|
||||||
days?: number;
|
days?: number;
|
||||||
|
brand_id?: number;
|
||||||
|
keyword_id?: number | null;
|
||||||
|
business_date?: string;
|
||||||
|
ai_platform_id?: string;
|
||||||
}) {
|
}) {
|
||||||
return apiClient.get<MonitoringCitationSummaryResponse>("/api/tenant/monitoring/citation-summary", {
|
return apiClient.get<MonitoringCitationSummaryResponse>("/api/tenant/monitoring/citation-summary", {
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ html, body, #app {
|
|||||||
|
|
||||||
.ant-layout-header {
|
.ant-layout-header {
|
||||||
box-shadow: 0 1px 4px rgba(0,21,41,0.08); /* slight shadow for topbar */
|
box-shadow: 0 1px 4px rgba(0,21,41,0.08); /* slight shadow for topbar */
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-card {
|
.ant-card {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ const recentTemplateArticles = computed<ArticleListItem[]>(() =>
|
|||||||
prompt_rule_id: null,
|
prompt_rule_id: null,
|
||||||
prompt_rule_name: null,
|
prompt_rule_name: null,
|
||||||
generation_mode: article.generation_mode,
|
generation_mode: article.generation_mode,
|
||||||
platforms: [],
|
auto_publish_platforms: [],
|
||||||
generate_status: article.generate_status,
|
generate_status: article.generate_status,
|
||||||
publish_status: article.publish_status,
|
publish_status: article.publish_status,
|
||||||
title: article.title,
|
title: article.title,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
MonitoringPlatformAuthorizationStatus,
|
MonitoringPlatformAuthorizationStatus,
|
||||||
} from "@geo/shared-types";
|
} from "@geo/shared-types";
|
||||||
import { aiPlatformCatalog } from "@geo/shared-types";
|
import { aiPlatformCatalog } from "@geo/shared-types";
|
||||||
import { computed, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
@@ -251,10 +251,19 @@ const citationSummaryQuery = useQuery({
|
|||||||
queryKey: computed(() => [
|
queryKey: computed(() => [
|
||||||
"tracking",
|
"tracking",
|
||||||
"citation-summary",
|
"citation-summary",
|
||||||
|
selectedBrandId.value,
|
||||||
|
selectedKeywordId.value,
|
||||||
|
selectedPlatformId.value,
|
||||||
|
selectedBusinessDate.value,
|
||||||
selectedCitationWindowDays.value,
|
selectedCitationWindowDays.value,
|
||||||
]),
|
]),
|
||||||
|
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
monitoringApi.citationSummary({
|
monitoringApi.citationSummary({
|
||||||
|
brand_id: selectedBrandId.value ?? undefined,
|
||||||
|
keyword_id: selectedKeywordId.value,
|
||||||
|
business_date: selectedBusinessDate.value,
|
||||||
|
ai_platform_id: selectedPlatformId.value !== "all" ? selectedPlatformId.value : undefined,
|
||||||
days: selectedCitationWindowDays.value,
|
days: selectedCitationWindowDays.value,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -495,25 +504,72 @@ const visibleCitedArticles = computed(() => {
|
|||||||
return citationSummaryQuery.data.value?.cited_articles ?? [];
|
return citationSummaryQuery.data.value?.cited_articles ?? [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const citationChartStyle = computed(() => {
|
const citationPieSlices = computed(() => {
|
||||||
const segments = citationRankingRows.value.filter((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0);
|
const segments = citationRankingRows.value.filter((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0);
|
||||||
|
|
||||||
if (!segments.length) {
|
if (!segments.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cx = 100;
|
||||||
|
const cy = 100;
|
||||||
|
const r = 100;
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
return segments.map((item) => {
|
||||||
|
const share = item.share;
|
||||||
|
|
||||||
|
if (share === 1) {
|
||||||
return {
|
return {
|
||||||
background: "linear-gradient(180deg, #eef4ff 0%, #e5ecff 100%)",
|
...item,
|
||||||
|
isFull: true,
|
||||||
|
path: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let offset = 0;
|
const startAngle = offset * 2 * Math.PI - Math.PI / 2;
|
||||||
const gradientStops = segments.map((item) => {
|
const endAngle = (offset + share) * 2 * Math.PI - Math.PI / 2;
|
||||||
const next = offset + item.share * 360;
|
|
||||||
const stop = `${item.color} ${offset.toFixed(2)}deg ${next.toFixed(2)}deg`;
|
const x1 = cx + r * Math.cos(startAngle);
|
||||||
offset = next;
|
const y1 = cy + r * Math.sin(startAngle);
|
||||||
return stop;
|
const x2 = cx + r * Math.cos(endAngle);
|
||||||
});
|
const y2 = cy + r * Math.sin(endAngle);
|
||||||
|
|
||||||
|
const largeArcFlag = share > 0.5 ? 1 : 0;
|
||||||
|
const path = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArcFlag} 1 ${x2} ${y2} Z`;
|
||||||
|
|
||||||
|
offset += share;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
background: `conic-gradient(${gradientStops.join(", ")})`,
|
...item,
|
||||||
|
isFull: false,
|
||||||
|
path,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hoveredSlice = ref<string | null>(null);
|
||||||
|
const tooltipPos = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
function onPieMouseMove(e: MouseEvent) {
|
||||||
|
const target = e.currentTarget as HTMLElement;
|
||||||
|
if (!target) return;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
tooltipPos.value = {
|
||||||
|
x: e.clientX - rect.left + 15,
|
||||||
|
y: e.clientY - rect.top + 15
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoveredSliceInfo = computed(() => {
|
||||||
|
if (!hoveredSlice.value) return null;
|
||||||
|
return citationPieSlices.value.find(s => s.ai_platform_id === hoveredSlice.value) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const citationChartStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
background: "linear-gradient(180deg, #eef4ff 0%, #e5ecff 100%)",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -918,8 +974,38 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
|||||||
|
|
||||||
<div class="citation-ranking-layout">
|
<div class="citation-ranking-layout">
|
||||||
<div class="citation-ranking-visual">
|
<div class="citation-ranking-visual">
|
||||||
<div class="citation-ranking-visual__canvas">
|
<div class="citation-ranking-visual__canvas" @mouseleave="hoveredSlice = null" @mousemove="onPieMouseMove">
|
||||||
<div class="citation-ranking-visual__circle" :style="citationChartStyle" />
|
<svg v-if="citationPieSlices.length" viewBox="0 0 200 200" class="citation-pie-svg">
|
||||||
|
<template v-for="slice in citationPieSlices" :key="slice.ai_platform_id">
|
||||||
|
<circle
|
||||||
|
v-if="slice.isFull"
|
||||||
|
cx="100" cy="100" r="100"
|
||||||
|
:fill="slice.color"
|
||||||
|
class="citation-pie-slice"
|
||||||
|
:class="{ 'is-hovered': hoveredSlice === slice.ai_platform_id }"
|
||||||
|
@mouseenter="hoveredSlice = slice.ai_platform_id"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-else
|
||||||
|
:d="slice.path"
|
||||||
|
:fill="slice.color"
|
||||||
|
class="citation-pie-slice"
|
||||||
|
:class="{ 'is-hovered': hoveredSlice === slice.ai_platform_id, 'is-dimmed': hoveredSlice && hoveredSlice !== slice.ai_platform_id }"
|
||||||
|
@mouseenter="hoveredSlice = slice.ai_platform_id"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
<div v-else class="citation-ranking-visual__circle" :style="citationChartStyle" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="hoveredSliceInfo"
|
||||||
|
class="citation-pie-tooltip"
|
||||||
|
:style="{ left: `${tooltipPos.x}px`, top: `${tooltipPos.y}px` }"
|
||||||
|
>
|
||||||
|
<div class="citation-pie-tooltip__marker" :style="{ background: hoveredSliceInfo.color }"></div>
|
||||||
|
<span class="citation-pie-tooltip__label">{{ hoveredSliceInfo.platform_name }}</span>
|
||||||
|
<strong class="citation-pie-tooltip__value">{{ formatPercent(hoveredSliceInfo.share) }}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="citationRankingRows.length" class="citation-ranking-legend">
|
<div v-if="citationRankingRows.length" class="citation-ranking-legend">
|
||||||
@@ -1390,12 +1476,74 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.citation-ranking-visual__canvas {
|
.citation-ranking-visual__canvas {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.citation-pie-svg {
|
||||||
|
width: min(32vw, 420px);
|
||||||
|
height: min(32vw, 420px);
|
||||||
|
min-width: 280px;
|
||||||
|
min-height: 280px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: visible;
|
||||||
|
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-pie-slice {
|
||||||
|
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s;
|
||||||
|
transform-origin: 100px 100px;
|
||||||
|
cursor: pointer;
|
||||||
|
stroke: #ffffff;
|
||||||
|
stroke-width: 1.5px;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-pie-slice.is-hovered {
|
||||||
|
transform: scale(1.05);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-pie-slice.is-dimmed {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-pie-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-pie-tooltip__marker {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-pie-tooltip__label {
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-pie-tooltip__value {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.citation-ranking-visual__circle {
|
.citation-ranking-visual__circle {
|
||||||
width: min(32vw, 420px);
|
width: min(32vw, 420px);
|
||||||
height: min(32vw, 420px);
|
height: min(32vw, 420px);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
appId: com.geo.rankly.desktop
|
appId: com.geo.rankly.desktop
|
||||||
productName: GEO Rankly Desktop
|
productName: 省心推
|
||||||
copyright: Copyright © 2026 GEO Rankly
|
copyright: Copyright © 2026 shengxintui.com. All Rights Reserved.
|
||||||
directories:
|
directories:
|
||||||
output: release
|
output: release
|
||||||
buildResources: build
|
buildResources: build
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
"name": "@geo/desktop-client",
|
"name": "@geo/desktop-client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "GEO Rankly Desktop — account health, monitor scheduler, and publish dispatch client.",
|
"description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "GEO Rankly",
|
"name": "Liang Xu",
|
||||||
"email": "support@geo-rankly.local"
|
"email": "liangxu@qq.com",
|
||||||
|
"url": "https://shengxintui.com"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "out/main/bootstrap.cjs",
|
"main": "out/main/bootstrap.cjs",
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ async function createAppWindow(mode: WindowMode): Promise<ElectronBrowserWindow>
|
|||||||
minWidth: initial.minWidth,
|
minWidth: initial.minWidth,
|
||||||
minHeight: initial.minHeight,
|
minHeight: initial.minHeight,
|
||||||
show: false,
|
show: false,
|
||||||
title: "GEO Rankly Desktop",
|
title: "省心推",
|
||||||
icon: createDesktopHealthIcon("normal"),
|
icon: createDesktopHealthIcon("normal"),
|
||||||
titleBarStyle: "hiddenInset",
|
titleBarStyle: "hiddenInset",
|
||||||
trafficLightPosition: { x: 12, y: 14 },
|
trafficLightPosition: { x: 12, y: 14 },
|
||||||
@@ -414,7 +414,7 @@ async function createSettingsWindow(): Promise<ElectronBrowserWindow> {
|
|||||||
minWidth: initial.minWidth,
|
minWidth: initial.minWidth,
|
||||||
minHeight: initial.minHeight,
|
minHeight: initial.minHeight,
|
||||||
show: false,
|
show: false,
|
||||||
title: "GEO Rankly Desktop 设置",
|
title: "省心推设置",
|
||||||
icon: createDesktopHealthIcon("normal"),
|
icon: createDesktopHealthIcon("normal"),
|
||||||
titleBarStyle: "hiddenInset",
|
titleBarStyle: "hiddenInset",
|
||||||
trafficLightPosition: { x: 14, y: 15 },
|
trafficLightPosition: { x: 14, y: 15 },
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("playwright cdp target pruning", () => {
|
|||||||
expect(isReclaimableHiddenPlaywrightTarget({
|
expect(isReclaimableHiddenPlaywrightTarget({
|
||||||
id: "renderer",
|
id: "renderer",
|
||||||
type: "page",
|
type: "page",
|
||||||
title: "GEO Rankly Desktop",
|
title: "省心推 - 账号健康监控与内容发布平台",
|
||||||
url: "http://localhost:5173/",
|
url: "http://localhost:5173/",
|
||||||
})).toBe(false);
|
})).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ function createLiveRuntimeSnapshot(controller: RuntimeControllerSnapshot) {
|
|||||||
return {
|
return {
|
||||||
generatedAt: now,
|
generatedAt: now,
|
||||||
app: {
|
app: {
|
||||||
name: "GEO Rankly Desktop",
|
name: "省心推",
|
||||||
version: app.getVersion(),
|
version: app.getVersion(),
|
||||||
channel: process.env.NODE_ENV === "development" ? "dev" : "release",
|
channel: process.env.NODE_ENV === "development" ? "dev" : "release",
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function updateTrayIssueIndicator(issueCount: number): void {
|
|||||||
|
|
||||||
tray.setImage(createTrayIcon(hasIssues ? "danger" : "normal"));
|
tray.setImage(createTrayIcon(hasIssues ? "danger" : "normal"));
|
||||||
tray.setTitle(hasIssues ? formatIssueCount(safeCount) : "", { fontType: "monospacedDigit" });
|
tray.setTitle(hasIssues ? formatIssueCount(safeCount) : "", { fontType: "monospacedDigit" });
|
||||||
tray.setToolTip(hasIssues ? `GEO Rankly Desktop · ${safeCount} 个账号健康问题` : "GEO Rankly Desktop");
|
tray.setToolTip(hasIssues ? `省心推 · ${safeCount} 个账号健康问题` : "省心推");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initTray(onOpen: () => void): ElectronTray {
|
export function initTray(onOpen: () => void): ElectronTray {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function openSettingsWindow() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Workspace Cockpit</p>
|
<p class="eyebrow">Workspace Cockpit</p>
|
||||||
<h1>GEO Rankly Desktop</h1>
|
<h1>省心推</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
rel="icon"
|
rel="icon"
|
||||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%230f766e'/%3E%3Cstop offset='1' stop-color='%231b6cff'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='18' fill='%23f5f7fb'/%3E%3Ccircle cx='32' cy='32' r='18' fill='none' stroke='url(%23g)' stroke-width='6'/%3E%3Ccircle cx='32' cy='32' r='8' fill='url(%23g)'/%3E%3C/svg%3E"
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%230f766e'/%3E%3Cstop offset='1' stop-color='%231b6cff'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='18' fill='%23f5f7fb'/%3E%3Ccircle cx='32' cy='32' r='18' fill='none' stroke='url(%23g)' stroke-width='6'/%3E%3Ccircle cx='32' cy='32' r='8' fill='url(%23g)'/%3E%3C/svg%3E"
|
||||||
/>
|
/>
|
||||||
<title>GEO Rankly Desktop</title>
|
<title>省心推</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ onBeforeUnmount(() => {
|
|||||||
<div class="brand-logo">
|
<div class="brand-logo">
|
||||||
<span class="brand-core"></span>
|
<span class="brand-core"></span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="brand-name">GEO Rankly</h1>
|
<h1 class="brand-name">省心推</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="login-form" @submit.prevent="submitLogin">
|
<form class="login-form" @submit.prevent="submitLogin">
|
||||||
|
|||||||
@@ -210,7 +210,6 @@ runtime:
|
|||||||
prompt_rule_scene_supplement_format: "适用场景:%s"
|
prompt_rule_scene_supplement_format: "适用场景:%s"
|
||||||
prompt_rule_tone_supplement_format: "默认语气:%s"
|
prompt_rule_tone_supplement_format: "默认语气:%s"
|
||||||
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
|
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
|
||||||
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
|
|
||||||
prompt_rule_supplement_heading: "补充要求:"
|
prompt_rule_supplement_heading: "补充要求:"
|
||||||
prompt_rule_output_requirements_section: |
|
prompt_rule_output_requirements_section: |
|
||||||
输出要求:
|
输出要求:
|
||||||
|
|||||||
@@ -210,7 +210,6 @@ runtime:
|
|||||||
prompt_rule_scene_supplement_format: "适用场景:%s"
|
prompt_rule_scene_supplement_format: "适用场景:%s"
|
||||||
prompt_rule_tone_supplement_format: "默认语气:%s"
|
prompt_rule_tone_supplement_format: "默认语气:%s"
|
||||||
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
|
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
|
||||||
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
|
|
||||||
prompt_rule_supplement_heading: "补充要求:"
|
prompt_rule_supplement_heading: "补充要求:"
|
||||||
prompt_rule_output_requirements_section: |
|
prompt_rule_output_requirements_section: |
|
||||||
输出要求:
|
输出要求:
|
||||||
|
|||||||
@@ -557,7 +557,7 @@ export interface ArticleListItem {
|
|||||||
prompt_rule_id: number | null;
|
prompt_rule_id: number | null;
|
||||||
prompt_rule_name: string | null;
|
prompt_rule_name: string | null;
|
||||||
generation_mode: string | null;
|
generation_mode: string | null;
|
||||||
platforms: string[];
|
auto_publish_platforms: string[];
|
||||||
generate_status: string;
|
generate_status: string;
|
||||||
publish_status: string;
|
publish_status: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
@@ -582,7 +582,7 @@ export interface ArticleDetail {
|
|||||||
template_id: number | null;
|
template_id: number | null;
|
||||||
template_name: string | null;
|
template_name: string | null;
|
||||||
generation_mode: string | null;
|
generation_mode: string | null;
|
||||||
platforms: string[];
|
auto_publish_platforms: string[];
|
||||||
cover_asset_url: string | null;
|
cover_asset_url: string | null;
|
||||||
cover_image_asset_id: number | null;
|
cover_image_asset_id: number | null;
|
||||||
generate_status: string;
|
generate_status: string;
|
||||||
@@ -632,7 +632,6 @@ export interface GenerateImitationResponse {
|
|||||||
export interface UpdateArticleRequest {
|
export interface UpdateArticleRequest {
|
||||||
title: string;
|
title: string;
|
||||||
markdown_content: string;
|
markdown_content: string;
|
||||||
platforms?: string[];
|
|
||||||
cover_asset_url?: string | null;
|
cover_asset_url?: string | null;
|
||||||
referenced_image_asset_ids?: number[];
|
referenced_image_asset_ids?: number[];
|
||||||
cover_image_asset_id?: number | null;
|
cover_image_asset_id?: number | null;
|
||||||
@@ -1448,6 +1447,7 @@ export interface ScheduleTask {
|
|||||||
cron_expr: string;
|
cron_expr: string;
|
||||||
auto_publish: boolean;
|
auto_publish: boolean;
|
||||||
publish_account_ids: string[];
|
publish_account_ids: string[];
|
||||||
|
auto_publish_platforms: string[];
|
||||||
cover_asset_url: string | null;
|
cover_asset_url: string | null;
|
||||||
cover_image_asset_id: number | null;
|
cover_image_asset_id: number | null;
|
||||||
enable_web_search: boolean;
|
enable_web_search: boolean;
|
||||||
@@ -1534,7 +1534,7 @@ export interface InstantTaskItem {
|
|||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
execution_time: string | null;
|
execution_time: string | null;
|
||||||
platforms: string[];
|
auto_publish_platforms: string[];
|
||||||
generate_count: number;
|
generate_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,7 +210,6 @@ runtime:
|
|||||||
prompt_rule_scene_supplement_format: "适用场景:%s"
|
prompt_rule_scene_supplement_format: "适用场景:%s"
|
||||||
prompt_rule_tone_supplement_format: "默认语气:%s"
|
prompt_rule_tone_supplement_format: "默认语气:%s"
|
||||||
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
|
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
|
||||||
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
|
|
||||||
prompt_rule_supplement_heading: "补充要求:"
|
prompt_rule_supplement_heading: "补充要求:"
|
||||||
prompt_rule_output_requirements_section: |
|
prompt_rule_output_requirements_section: |
|
||||||
输出要求:
|
输出要求:
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func BuildOpenAPI(routes gin.RoutesInfo, cfg Config) map[string]any {
|
|||||||
|
|
||||||
func withDefaults(cfg Config) Config {
|
func withDefaults(cfg Config) Config {
|
||||||
if strings.TrimSpace(cfg.Title) == "" {
|
if strings.TrimSpace(cfg.Title) == "" {
|
||||||
cfg.Title = "Geo Rankly Tenant API"
|
cfg.Title = "省心推 客户 API"
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.Version) == "" {
|
if strings.TrimSpace(cfg.Version) == "" {
|
||||||
cfg.Version = "dev"
|
cfg.Version = "dev"
|
||||||
@@ -557,7 +557,7 @@ const swaggerIndexHTML = `<!doctype html>
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Geo Rankly API Swagger</title>
|
<title>省心推 API Swagger</title>
|
||||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ type ArticleListItem struct {
|
|||||||
PromptRuleID *int64 `json:"prompt_rule_id"`
|
PromptRuleID *int64 `json:"prompt_rule_id"`
|
||||||
PromptRuleName *string `json:"prompt_rule_name"`
|
PromptRuleName *string `json:"prompt_rule_name"`
|
||||||
GenerationMode *string `json:"generation_mode"`
|
GenerationMode *string `json:"generation_mode"`
|
||||||
Platforms []string `json:"platforms"`
|
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
||||||
GenerateStatus string `json:"generate_status"`
|
GenerateStatus string `json:"generate_status"`
|
||||||
PublishStatus string `json:"publish_status"`
|
PublishStatus string `json:"publish_status"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
@@ -147,7 +147,7 @@ type ArticleDetailResponse struct {
|
|||||||
TemplateID *int64 `json:"template_id"`
|
TemplateID *int64 `json:"template_id"`
|
||||||
TemplateName *string `json:"template_name"`
|
TemplateName *string `json:"template_name"`
|
||||||
GenerationMode *string `json:"generation_mode"`
|
GenerationMode *string `json:"generation_mode"`
|
||||||
Platforms []string `json:"platforms"`
|
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
||||||
CoverAssetURL *string `json:"cover_asset_url"`
|
CoverAssetURL *string `json:"cover_asset_url"`
|
||||||
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
||||||
GenerateStatus string `json:"generate_status"`
|
GenerateStatus string `json:"generate_status"`
|
||||||
@@ -355,7 +355,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
|||||||
}
|
}
|
||||||
item.SourceType = dbSourceType
|
item.SourceType = dbSourceType
|
||||||
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
autoPublishPlatforms, err := loadScheduleAutoPublishPlatforms(ctx, s.pool, tenantID, inputParamsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50010, "query_failed", "failed to resolve article auto publish platforms")
|
||||||
|
}
|
||||||
|
item.AutoPublishPlatforms = autoPublishPlatforms
|
||||||
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
|
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
@@ -407,7 +411,11 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
|
|||||||
_ = json.Unmarshal(wizardStateJSON, &item.WizardState)
|
_ = json.Unmarshal(wizardStateJSON, &item.WizardState)
|
||||||
}
|
}
|
||||||
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
autoPublishPlatforms, err := loadScheduleAutoPublishPlatforms(ctx, s.pool, tenantID, inputParamsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, response.ErrInternal(50010, "query_failed", "failed to resolve article auto publish platforms")
|
||||||
|
}
|
||||||
|
item.AutoPublishPlatforms = autoPublishPlatforms
|
||||||
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
|
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
|
||||||
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
||||||
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
|
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
|
||||||
@@ -495,7 +503,6 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (
|
|||||||
type UpdateArticleRequest struct {
|
type UpdateArticleRequest struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
MarkdownContent string `json:"markdown_content"`
|
MarkdownContent string `json:"markdown_content"`
|
||||||
Platforms []string `json:"platforms"`
|
|
||||||
CoverAssetURL *string `json:"cover_asset_url"`
|
CoverAssetURL *string `json:"cover_asset_url"`
|
||||||
ReferencedImageAssetIDs []int64 `json:"referenced_image_asset_ids"`
|
ReferencedImageAssetIDs []int64 `json:"referenced_image_asset_ids"`
|
||||||
CoverImageAssetID NullableInt64Input `json:"cover_image_asset_id"`
|
CoverImageAssetID NullableInt64Input `json:"cover_image_asset_id"`
|
||||||
@@ -577,7 +584,6 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
|||||||
nextWizardStateJSON, err := mergeArticleWizardState(
|
nextWizardStateJSON, err := mergeArticleWizardState(
|
||||||
wizardStateJSON,
|
wizardStateJSON,
|
||||||
title,
|
title,
|
||||||
req.Platforms,
|
|
||||||
req.CoverAssetURL,
|
req.CoverAssetURL,
|
||||||
resolvedCoverImageAssetInput,
|
resolvedCoverImageAssetInput,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ type InstantTaskResponse struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ExecutionTime *string `json:"execution_time"`
|
ExecutionTime *string `json:"execution_time"`
|
||||||
Platforms []string `json:"platforms"`
|
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
||||||
GenerateCount int `json:"generate_count"`
|
GenerateCount int `json:"generate_count"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,11 @@ func (s *InstantTaskService) List(ctx context.Context, params InstantTaskListPar
|
|||||||
|
|
||||||
item.ExecutionTime = stringPtrFromDBValue(executionAt)
|
item.ExecutionTime = stringPtrFromDBValue(executionAt)
|
||||||
item.CreatedAt = fmt.Sprintf("%v", createdAt)
|
item.CreatedAt = fmt.Sprintf("%v", createdAt)
|
||||||
item.Platforms = resolveArticlePlatforms(nil, inputParamsJSON)
|
autoPublishPlatforms, err := loadScheduleAutoPublishPlatforms(ctx, s.pool, actor.TenantID, inputParamsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50010, "query_failed", "failed to resolve instant task auto publish platforms")
|
||||||
|
}
|
||||||
|
item.AutoPublishPlatforms = autoPublishPlatforms
|
||||||
item.GenerateCount = 1
|
item.GenerateCount = 1
|
||||||
|
|
||||||
if len(inputParamsJSON) > 0 {
|
if len(inputParamsJSON) > 0 {
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ type monitoringAliasResolution struct {
|
|||||||
|
|
||||||
type monitoringAliasLookupInput struct {
|
type monitoringAliasLookupInput struct {
|
||||||
NormalizedURL string
|
NormalizedURL string
|
||||||
|
MatchKeys []string
|
||||||
RegistrableDomain string
|
RegistrableDomain string
|
||||||
SiteKey string
|
SiteKey string
|
||||||
Host string
|
Host string
|
||||||
@@ -293,6 +294,7 @@ type monitoringAliasLookupInput struct {
|
|||||||
|
|
||||||
type monitoringAliasCandidate struct {
|
type monitoringAliasCandidate struct {
|
||||||
NormalizedURL string
|
NormalizedURL string
|
||||||
|
MatchKeys []string
|
||||||
ArticleID *int64
|
ArticleID *int64
|
||||||
PublishRecordID *int64
|
PublishRecordID *int64
|
||||||
RegistrableDomain string
|
RegistrableDomain string
|
||||||
@@ -2444,6 +2446,7 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
|
|||||||
}
|
}
|
||||||
lookupInputs = append(lookupInputs, monitoringAliasLookupInput{
|
lookupInputs = append(lookupInputs, monitoringAliasLookupInput{
|
||||||
NormalizedURL: key,
|
NormalizedURL: key,
|
||||||
|
MatchKeys: monitoringCitationCandidateKeys(item.URL, key),
|
||||||
RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)),
|
RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)),
|
||||||
SiteKey: strings.ToLower(strings.TrimSpace(siteKey)),
|
SiteKey: strings.ToLower(strings.TrimSpace(siteKey)),
|
||||||
Host: strings.ToLower(strings.TrimSpace(host)),
|
Host: strings.ToLower(strings.TrimSpace(host)),
|
||||||
@@ -2557,6 +2560,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
|
|||||||
candidate.Host = strings.ToLower(strings.TrimSpace(nullableStringText(host)))
|
candidate.Host = strings.ToLower(strings.TrimSpace(nullableStringText(host)))
|
||||||
candidate.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment))
|
candidate.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment))
|
||||||
candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle))
|
candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle))
|
||||||
|
candidate.MatchKeys = monitoringCitationCandidateKeys(candidate.NormalizedURL, candidate.NormalizedURL)
|
||||||
candidates = append(candidates, candidate)
|
candidates = append(candidates, candidate)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -2567,11 +2571,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
|
|||||||
bestScore := -1
|
bestScore := -1
|
||||||
bestAmbiguous := false
|
bestAmbiguous := false
|
||||||
var best monitoringAliasCandidate
|
var best monitoringAliasCandidate
|
||||||
domainMatchedArticleIDs := map[int64]struct{}{}
|
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if monitoringAliasDomainMatches(input, candidate) && candidate.ArticleID != nil {
|
|
||||||
domainMatchedArticleIDs[*candidate.ArticleID] = struct{}{}
|
|
||||||
}
|
|
||||||
score := scoreMonitoringAliasCandidate(input, candidate)
|
score := scoreMonitoringAliasCandidate(input, candidate)
|
||||||
if score < 0 {
|
if score < 0 {
|
||||||
continue
|
continue
|
||||||
@@ -2586,27 +2586,16 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
|
|||||||
bestAmbiguous = true
|
bestAmbiguous = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if bestScore == 40 && len(domainMatchedArticleIDs) == 1 {
|
|
||||||
bestScore = 60
|
|
||||||
}
|
|
||||||
|
|
||||||
if bestScore < 60 || bestAmbiguous {
|
if bestScore < 100 || bestAmbiguous {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
confidence := "medium"
|
|
||||||
status := "domain_matched"
|
|
||||||
if bestScore >= 100 {
|
|
||||||
confidence = "high"
|
|
||||||
status = "resolved"
|
|
||||||
} else if bestScore < 80 {
|
|
||||||
confidence = "low"
|
|
||||||
}
|
|
||||||
result[input.NormalizedURL] = monitoringAliasResolution{
|
result[input.NormalizedURL] = monitoringAliasResolution{
|
||||||
ArticleID: cloneInt64(best.ArticleID),
|
ArticleID: cloneInt64(best.ArticleID),
|
||||||
PublishRecordID: cloneInt64(best.PublishRecordID),
|
PublishRecordID: cloneInt64(best.PublishRecordID),
|
||||||
NormalizedURLKey: best.NormalizedURL,
|
NormalizedURLKey: best.NormalizedURL,
|
||||||
ResolutionStatus: status,
|
ResolutionStatus: "resolved",
|
||||||
ResolutionConfidence: confidence,
|
ResolutionConfidence: "high",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2618,23 +2607,19 @@ func scoreMonitoringAliasCandidate(input monitoringAliasLookupInput, candidate m
|
|||||||
return 100
|
return 100
|
||||||
}
|
}
|
||||||
|
|
||||||
if !monitoringAliasDomainMatches(input, candidate) {
|
for _, inputKey := range input.MatchKeys {
|
||||||
return -1
|
inputKey = strings.TrimSpace(inputKey)
|
||||||
|
if inputKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, candidateKey := range candidate.MatchKeys {
|
||||||
|
if inputKey == strings.TrimSpace(candidateKey) && candidateKey != "" {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
score := 40
|
return -1
|
||||||
if input.LastPathSegment != nil && strings.TrimSpace(*input.LastPathSegment) != "" && candidate.LastPathSegment == strings.TrimSpace(*input.LastPathSegment) {
|
|
||||||
score += 30
|
|
||||||
}
|
|
||||||
if input.TitleKey != "" && candidate.TitleKey != "" {
|
|
||||||
switch {
|
|
||||||
case input.TitleKey == candidate.TitleKey:
|
|
||||||
score += 30
|
|
||||||
case strings.Contains(input.TitleKey, candidate.TitleKey) || strings.Contains(candidate.TitleKey, input.TitleKey):
|
|
||||||
score += 15
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return score
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool {
|
func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool {
|
||||||
|
|||||||
@@ -425,23 +425,33 @@ func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScoreMonitoringAliasCandidateUsesDomainWithTitleEvidence(t *testing.T) {
|
func TestScoreMonitoringAliasCandidateUsesCanonicalArticleKey(t *testing.T) {
|
||||||
lastPath := "article-42"
|
|
||||||
input := monitoringAliasLookupInput{
|
input := monitoringAliasLookupInput{
|
||||||
NormalizedURL: "https://m.example.com/article-42",
|
NormalizedURL: "https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html",
|
||||||
RegistrableDomain: "example.com",
|
MatchKeys: monitoringCitationCandidateKeys("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
|
||||||
LastPathSegment: &lastPath,
|
|
||||||
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
|
|
||||||
}
|
}
|
||||||
candidate := monitoringAliasCandidate{
|
candidate := monitoringAliasCandidate{
|
||||||
NormalizedURL: "https://www.example.com/posts/article-42",
|
NormalizedURL: "https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html",
|
||||||
RegistrableDomain: "example.com",
|
MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
|
||||||
LastPathSegment: "article-42",
|
|
||||||
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if score := scoreMonitoringAliasCandidate(input, candidate); score < 90 {
|
if score := scoreMonitoringAliasCandidate(input, candidate); score != 100 {
|
||||||
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want strong domain/title match", score)
|
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want canonical URL match", score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScoreMonitoringAliasCandidateRejectsSameDomainDifferentArticle(t *testing.T) {
|
||||||
|
input := monitoringAliasLookupInput{
|
||||||
|
NormalizedURL: "https://www.163.com/dy/article/KIN5DDMK0556GQZ8.html",
|
||||||
|
MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KIN5DDMK0556GQZ8.html", ""),
|
||||||
|
}
|
||||||
|
candidate := monitoringAliasCandidate{
|
||||||
|
NormalizedURL: "https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html",
|
||||||
|
MatchKeys: monitoringCitationCandidateKeys("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
if score := scoreMonitoringAliasCandidate(input, candidate); score != -1 {
|
||||||
|
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want no same-domain match", score)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type monitoringPublishedAlias struct {
|
||||||
|
ArticleID int64
|
||||||
|
PublishRecordID *int64
|
||||||
|
ArticleTitle string
|
||||||
|
PublishPlatform string
|
||||||
|
Ambiguous bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type monitoringPublishedAliasIndex map[string]monitoringPublishedAlias
|
||||||
|
|
||||||
|
type monitoringAliasQuerier interface {
|
||||||
|
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMonitoringPublishedAliasIndex(ctx context.Context, q monitoringAliasQuerier, tenantID int64) (monitoringPublishedAliasIndex, error) {
|
||||||
|
rows, err := q.Query(ctx, `
|
||||||
|
SELECT
|
||||||
|
article_id,
|
||||||
|
publish_record_id,
|
||||||
|
COALESCE(article_title_snapshot, '未命名文章') AS article_title,
|
||||||
|
COALESCE(publish_platform_name_snapshot, '已发布内容') AS publish_platform,
|
||||||
|
original_url,
|
||||||
|
normalized_url
|
||||||
|
FROM monitoring_article_url_aliases
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND confidence = 'high'
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
`, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, responseInternalAliasLookupError()
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
index := make(monitoringPublishedAliasIndex)
|
||||||
|
for rows.Next() {
|
||||||
|
var item monitoringPublishedAlias
|
||||||
|
var originalURL string
|
||||||
|
var normalizedURL string
|
||||||
|
var publishRecordID sql.NullInt64
|
||||||
|
if scanErr := rows.Scan(&item.ArticleID, &publishRecordID, &item.ArticleTitle, &item.PublishPlatform, &originalURL, &normalizedURL); scanErr != nil {
|
||||||
|
return nil, responseInternalAliasScanError()
|
||||||
|
}
|
||||||
|
item.PublishRecordID = nullableInt64Value(publishRecordID)
|
||||||
|
for _, key := range monitoringCitationCandidateKeys(originalURL, normalizedURL) {
|
||||||
|
existing, exists := index[key]
|
||||||
|
if exists && existing.ArticleID != item.ArticleID {
|
||||||
|
existing.Ambiguous = true
|
||||||
|
index[key] = existing
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
index[key] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, responseInternalAliasScanError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return index, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (index monitoringPublishedAliasIndex) Match(rawURL, normalizedURL string) (monitoringPublishedAlias, bool) {
|
||||||
|
for _, key := range monitoringCitationCandidateKeys(rawURL, normalizedURL) {
|
||||||
|
item, ok := index[key]
|
||||||
|
if ok && !item.Ambiguous {
|
||||||
|
return item, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return monitoringPublishedAlias{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringCitationCandidateKeys(rawURL, normalizedURL string) []string {
|
||||||
|
keys := []string{
|
||||||
|
monitoringCitationURLMatchKey(rawURL),
|
||||||
|
monitoringCitationURLMatchKey(normalizedURL),
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(keys))
|
||||||
|
seen := make(map[string]struct{}, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
result = append(result, key)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringCitationURLMatchKey(raw string) string {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(trimmed)
|
||||||
|
if err != nil || parsed.Hostname() == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
host := monitoringCanonicalCitationHost(parsed.Hostname())
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(host, "163.com") {
|
||||||
|
if id := monitoringNeteaseArticleID(trimmed); id != "" {
|
||||||
|
return "163.com/dy/article/" + strings.ToLower(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if host == "baijiahao.baidu.com" {
|
||||||
|
if id := extractBaijiahaoArticleIDFromURL(&trimmed); id != "" {
|
||||||
|
return host + "/s?id=" + strings.ToLower(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimRight(parsed.EscapedPath(), "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if host == "mp.weixin.qq.com" {
|
||||||
|
if strings.HasPrefix(path, "/s/") && len(path) > len("/s/") {
|
||||||
|
return host + path
|
||||||
|
}
|
||||||
|
if query := monitoringCanonicalQuery(parsed, []string{"__biz", "mid", "idx", "sn"}); query != "" {
|
||||||
|
return host + path + "?" + query
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "/" {
|
||||||
|
if query := monitoringCanonicalQuery(parsed, monitoringGenericContentQueryKeys()); query != "" {
|
||||||
|
return host + path + "?" + query
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if query := monitoringCanonicalQuery(parsed, monitoringGenericContentQueryKeys()); query != "" {
|
||||||
|
return host + path + "?" + query
|
||||||
|
}
|
||||||
|
return host + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringCanonicalCitationHost(value string) string {
|
||||||
|
host := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if withoutPort, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = withoutPort
|
||||||
|
}
|
||||||
|
host = strings.Trim(host, ".")
|
||||||
|
parts := strings.Split(host, ".")
|
||||||
|
if len(parts) <= 2 {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
switch parts[0] {
|
||||||
|
case "www", "m", "wap", "mobile", "3g", "amp":
|
||||||
|
return strings.Join(parts[1:], ".")
|
||||||
|
default:
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringCanonicalQuery(parsed *url.URL, allowedKeys []string) string {
|
||||||
|
if parsed == nil || len(allowedKeys) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
values := parsed.Query()
|
||||||
|
pairs := make([]string, 0, len(allowedKeys))
|
||||||
|
for _, key := range allowedKeys {
|
||||||
|
value := strings.TrimSpace(values.Get(key))
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pairs = append(pairs, url.QueryEscape(key)+"="+url.QueryEscape(value))
|
||||||
|
}
|
||||||
|
sort.Strings(pairs)
|
||||||
|
return strings.Join(pairs, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringGenericContentQueryKeys() []string {
|
||||||
|
return []string{"id", "article_id", "articleId", "docid", "docId", "postId"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringNeteaseArticleID(rawURL string) string {
|
||||||
|
if id := extractWangyihaoArticleIDFromURL(&rawURL); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
for index := len(segments) - 1; index >= 0; index-- {
|
||||||
|
id := strings.TrimSuffix(strings.TrimSpace(segments[index]), ".html")
|
||||||
|
if monitoringLooksLikeNeteaseArticleID(id) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringLooksLikeNeteaseArticleID(value string) bool {
|
||||||
|
if len(value) < 8 || len(value) > 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasDigit := false
|
||||||
|
hasLetter := false
|
||||||
|
for _, r := range value {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
hasDigit = true
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
hasLetter = true
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
hasLetter = true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasDigit && hasLetter
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseInternalAliasLookupError() error {
|
||||||
|
return response.ErrInternal(50041, "alias_lookup_failed", "failed to resolve article aliases")
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseInternalAliasScanError() error {
|
||||||
|
return response.ErrInternal(50041, "alias_scan_failed", "failed to parse article alias resolution")
|
||||||
|
}
|
||||||
@@ -414,29 +414,55 @@ func (s *MonitoringService) DashboardComposite(
|
|||||||
func (s *MonitoringService) CitationSummary(
|
func (s *MonitoringService) CitationSummary(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
days int,
|
days int,
|
||||||
|
brandID int64,
|
||||||
|
keywordID *int64,
|
||||||
|
businessDate string,
|
||||||
|
aiPlatformID *string,
|
||||||
) (*MonitoringCitationSummaryResponse, error) {
|
) (*MonitoringCitationSummaryResponse, error) {
|
||||||
actor := auth.MustActor(ctx)
|
actor := auth.MustActor(ctx)
|
||||||
workspaceID := auth.CurrentWorkspaceID(ctx)
|
workspaceID := auth.CurrentWorkspaceID(ctx)
|
||||||
|
|
||||||
|
var questionIDs []int64
|
||||||
|
if brandID != 0 {
|
||||||
|
brand, err := s.resolveBrand(ctx, actor.TenantID, brandID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if keywordID != nil {
|
||||||
|
if err := s.ensureKeywordBelongsToBrand(ctx, actor.TenantID, brand.ID, *keywordID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configuredQuestions, err := s.loadConfiguredQuestions(ctx, actor.TenantID, brand.ID, keywordID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
questionIDs = configuredQuestionIDs(configuredQuestions)
|
||||||
|
}
|
||||||
|
|
||||||
quota, err := s.loadQuota(ctx, actor, workspaceID)
|
quota, err := s.loadQuota(ctx, actor, workspaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
citationWindowDays := normalizeCitationWindowDays(days)
|
citationWindowDays := normalizeCitationWindowDays(days)
|
||||||
startDate, endDate := trackingDateWindow(citationWindowDays)
|
startDate, endDate, err := resolveDashboardDateWindow(citationWindowDays, businessDate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate)
|
accessStates, err := s.loadAccessStates(ctx, actor.TenantID, workspaceID, quota.PrimaryClientID, endDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, 0, nil, startDate, endDate, accessStates, nil)
|
selectedPlatformID := normalizedOptionalMonitoringPlatformPointer(aiPlatformID)
|
||||||
|
citationRanking, err := s.loadCitationRanking(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, accessStates, selectedPlatformID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, 0, nil, startDate, endDate, nil)
|
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, selectedPlatformID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -2481,21 +2507,21 @@ func (s *MonitoringService) loadCitationRanking(
|
|||||||
}
|
}
|
||||||
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
||||||
|
|
||||||
|
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type citationRankingAggregate struct {
|
||||||
|
CitedAnswerCount int64
|
||||||
|
CitedArticleCount int64
|
||||||
|
CitationSourceCount int64
|
||||||
|
SaaSSourceCount int64
|
||||||
|
SampleCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregates := make(map[string]citationRankingAggregate)
|
||||||
rows, err := s.monitoringPool.Query(ctx, `
|
rows, err := s.monitoringPool.Query(ctx, `
|
||||||
WITH published_domains AS (
|
|
||||||
SELECT DISTINCT domain_key
|
|
||||||
FROM (
|
|
||||||
SELECT NULLIF(LOWER(TRIM(host)), '') AS domain_key
|
|
||||||
FROM monitoring_article_url_aliases
|
|
||||||
WHERE tenant_id = $1
|
|
||||||
UNION
|
|
||||||
SELECT NULLIF(LOWER(TRIM(site_key)), '') AS domain_key
|
|
||||||
FROM monitoring_article_url_aliases
|
|
||||||
WHERE tenant_id = $1
|
|
||||||
) domains
|
|
||||||
WHERE domain_key IS NOT NULL
|
|
||||||
),
|
|
||||||
run_counts AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
r.ai_platform_id,
|
r.ai_platform_id,
|
||||||
COUNT(*) AS sample_count
|
COUNT(*) AS sample_count
|
||||||
@@ -2508,25 +2534,38 @@ func (s *MonitoringService) loadCitationRanking(
|
|||||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||||
GROUP BY r.ai_platform_id
|
GROUP BY r.ai_platform_id
|
||||||
),
|
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
||||||
citation_sources AS (
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var platformID string
|
||||||
|
var sampleCount int64
|
||||||
|
if scanErr := rows.Scan(&platformID, &sampleCount); scanErr != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
|
||||||
|
}
|
||||||
|
platformID = normalizeMonitoringPlatformID(platformID)
|
||||||
|
if platformID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := aggregates[platformID]
|
||||||
|
item.SampleCount += sampleCount
|
||||||
|
aggregates[platformID] = item
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking")
|
||||||
|
}
|
||||||
|
|
||||||
|
citedRuns := make(map[string]map[int64]struct{})
|
||||||
|
citedArticles := make(map[string]map[int64]struct{})
|
||||||
|
rows, err = s.monitoringPool.Query(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
r.ai_platform_id,
|
r.ai_platform_id,
|
||||||
cf.id,
|
|
||||||
cf.run_id,
|
cf.run_id,
|
||||||
cf.article_id,
|
cf.cited_url,
|
||||||
(
|
cf.normalized_url
|
||||||
cf.article_id IS NOT NULL
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM published_domains pd
|
|
||||||
WHERE pd.domain_key = ANY(ARRAY[
|
|
||||||
LOWER(NULLIF(cf.registrable_domain, '')),
|
|
||||||
LOWER(NULLIF(cf.site_key, '')),
|
|
||||||
LOWER(NULLIF(cf.host, ''))
|
|
||||||
])
|
|
||||||
)
|
|
||||||
) AS matched_saas_source
|
|
||||||
FROM question_monitor_runs r
|
FROM question_monitor_runs r
|
||||||
JOIN monitoring_citation_facts cf
|
JOIN monitoring_citation_facts cf
|
||||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
||||||
@@ -2537,60 +2576,18 @@ func (s *MonitoringService) loadCitationRanking(
|
|||||||
AND r.business_date BETWEEN $4::date AND $5::date
|
AND r.business_date BETWEEN $4::date AND $5::date
|
||||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||||
),
|
|
||||||
citation_counts AS (
|
|
||||||
SELECT
|
|
||||||
ai_platform_id,
|
|
||||||
COUNT(*) AS citation_source_count,
|
|
||||||
COUNT(*) FILTER (WHERE matched_saas_source AND ai_platform_id <> 'qwen') AS saas_source_count,
|
|
||||||
COUNT(DISTINCT run_id) FILTER (WHERE matched_saas_source AND ai_platform_id <> 'qwen') AS cited_answer_count,
|
|
||||||
COUNT(DISTINCT article_id) FILTER (WHERE article_id IS NOT NULL AND ai_platform_id <> 'qwen') AS cited_article_count
|
|
||||||
FROM citation_sources
|
|
||||||
GROUP BY ai_platform_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
rc.ai_platform_id,
|
|
||||||
rc.sample_count,
|
|
||||||
COALESCE(cc.cited_answer_count, 0) AS cited_answer_count,
|
|
||||||
COALESCE(cc.cited_article_count, 0) AS cited_article_count,
|
|
||||||
COALESCE(cc.citation_source_count, 0) AS citation_source_count,
|
|
||||||
COALESCE(cc.saas_source_count, 0) AS saas_source_count,
|
|
||||||
CASE
|
|
||||||
WHEN COALESCE(cc.citation_source_count, 0) > 0
|
|
||||||
THEN COALESCE(cc.saas_source_count, 0)::double precision / cc.citation_source_count::double precision
|
|
||||||
ELSE NULL
|
|
||||||
END AS saas_source_rate,
|
|
||||||
CASE
|
|
||||||
WHEN rc.sample_count > 0 THEN COALESCE(cc.cited_answer_count, 0)::double precision / rc.sample_count
|
|
||||||
ELSE NULL
|
|
||||||
END AS citation_rate
|
|
||||||
FROM run_counts rc
|
|
||||||
LEFT JOIN citation_counts cc ON cc.ai_platform_id = rc.ai_platform_id
|
|
||||||
WHERE COALESCE(cc.saas_source_count, 0) > 0
|
|
||||||
ORDER BY cited_answer_count DESC, saas_source_count DESC, cited_article_count DESC, rc.ai_platform_id ASC
|
|
||||||
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
|
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
type citationRankingAggregate struct {
|
|
||||||
CitedAnswerCount int64
|
|
||||||
CitedArticleCount int64
|
|
||||||
CitationSourceCount int64
|
|
||||||
SaaSSourceCount int64
|
|
||||||
SampleCount int64
|
|
||||||
}
|
|
||||||
|
|
||||||
aggregates := make(map[string]citationRankingAggregate)
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var platformID string
|
var platformID string
|
||||||
var sampleCount int64
|
var runID int64
|
||||||
var citedAnswerCount int64
|
var citedURL string
|
||||||
var citedArticleCount int64
|
var normalizedURL string
|
||||||
var citationSourceCount int64
|
if scanErr := rows.Scan(&platformID, &runID, &citedURL, &normalizedURL); scanErr != nil {
|
||||||
var saasSourceCount int64
|
|
||||||
if scanErr := rows.Scan(&platformID, &sampleCount, &citedAnswerCount, &citedArticleCount, &citationSourceCount, &saasSourceCount, new(sql.NullFloat64), new(sql.NullFloat64)); scanErr != nil {
|
|
||||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
|
||||||
}
|
}
|
||||||
platformID = normalizeMonitoringPlatformID(platformID)
|
platformID = normalizeMonitoringPlatformID(platformID)
|
||||||
@@ -2598,16 +2595,36 @@ func (s *MonitoringService) loadCitationRanking(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
item := aggregates[platformID]
|
item := aggregates[platformID]
|
||||||
item.CitedAnswerCount += citedAnswerCount
|
item.CitationSourceCount++
|
||||||
item.CitedArticleCount += citedArticleCount
|
if platformID != "qwen" {
|
||||||
item.CitationSourceCount += citationSourceCount
|
if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
|
||||||
item.SaaSSourceCount += saasSourceCount
|
item.SaaSSourceCount++
|
||||||
item.SampleCount += sampleCount
|
if _, exists := citedRuns[platformID]; !exists {
|
||||||
|
citedRuns[platformID] = make(map[int64]struct{})
|
||||||
|
}
|
||||||
|
citedRuns[platformID][runID] = struct{}{}
|
||||||
|
if _, exists := citedArticles[platformID]; !exists {
|
||||||
|
citedArticles[platformID] = make(map[int64]struct{})
|
||||||
|
}
|
||||||
|
citedArticles[platformID][alias.ArticleID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aggregates[platformID] = item
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citation ranking")
|
||||||
|
}
|
||||||
|
for platformID, item := range aggregates {
|
||||||
|
item.CitedAnswerCount = int64(len(citedRuns[platformID]))
|
||||||
|
item.CitedArticleCount = int64(len(citedArticles[platformID]))
|
||||||
aggregates[platformID] = item
|
aggregates[platformID] = item
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]MonitoringCitationRanking, 0, len(aggregates))
|
items := make([]MonitoringCitationRanking, 0, len(aggregates))
|
||||||
for platformID, aggregate := range aggregates {
|
for platformID, aggregate := range aggregates {
|
||||||
|
if aggregate.SaaSSourceCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
items = append(items, MonitoringCitationRanking{
|
items = append(items, MonitoringCitationRanking{
|
||||||
AIPlatformID: platformID,
|
AIPlatformID: platformID,
|
||||||
PlatformName: platformDisplayName(platformID),
|
PlatformName: platformDisplayName(platformID),
|
||||||
@@ -2648,6 +2665,11 @@ func (s *MonitoringService) loadCitedArticles(
|
|||||||
}
|
}
|
||||||
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
||||||
|
|
||||||
|
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var totalSampleCount int64
|
var totalSampleCount int64
|
||||||
if err := s.monitoringPool.QueryRow(ctx, `
|
if err := s.monitoringPool.QueryRow(ctx, `
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
@@ -2664,78 +2686,78 @@ func (s *MonitoringService) loadCitedArticles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.monitoringPool.Query(ctx, `
|
rows, err := s.monitoringPool.Query(ctx, `
|
||||||
WITH article_counts AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
cf.article_id,
|
cf.cited_url,
|
||||||
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
|
cf.normalized_url
|
||||||
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
|
|
||||||
COUNT(*) AS citation_count
|
|
||||||
FROM monitoring_citation_facts cf
|
FROM monitoring_citation_facts cf
|
||||||
JOIN question_monitor_runs r
|
JOIN question_monitor_runs r
|
||||||
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT
|
|
||||||
alias.article_title_snapshot,
|
|
||||||
alias.publish_platform_name_snapshot
|
|
||||||
FROM monitoring_article_url_aliases alias
|
|
||||||
WHERE alias.tenant_id = cf.tenant_id
|
|
||||||
AND alias.article_id = cf.article_id
|
|
||||||
ORDER BY
|
|
||||||
(alias.publish_record_id = cf.publish_record_id) DESC NULLS LAST,
|
|
||||||
alias.updated_at DESC,
|
|
||||||
alias.id DESC
|
|
||||||
LIMIT 1
|
|
||||||
) alias ON TRUE
|
|
||||||
WHERE cf.tenant_id = $1
|
WHERE cf.tenant_id = $1
|
||||||
AND ($2::bigint = 0 OR cf.brand_id = $2)
|
AND ($2::bigint = 0 OR cf.brand_id = $2)
|
||||||
AND cf.article_id IS NOT NULL
|
|
||||||
AND r.ai_platform_id <> 'qwen'
|
AND r.ai_platform_id <> 'qwen'
|
||||||
AND r.collector_type = $3
|
AND r.collector_type = $3
|
||||||
AND r.status = 'succeeded'
|
AND r.status = 'succeeded'
|
||||||
AND r.business_date BETWEEN $4::date AND $5::date
|
AND r.business_date BETWEEN $4::date AND $5::date
|
||||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||||
GROUP BY cf.article_id
|
|
||||||
),
|
|
||||||
totals AS (
|
|
||||||
SELECT COALESCE(SUM(citation_count), 0) AS total_article_citation_count
|
|
||||||
FROM article_counts
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
cf.article_id,
|
|
||||||
cf.article_title,
|
|
||||||
cf.publish_platform,
|
|
||||||
cf.citation_count,
|
|
||||||
t.total_article_citation_count
|
|
||||||
FROM article_counts cf
|
|
||||||
CROSS JOIN totals t
|
|
||||||
ORDER BY citation_count DESC, cf.article_id ASC
|
|
||||||
LIMIT 10
|
|
||||||
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles")
|
return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles")
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
items := make([]MonitoringCitedArticle, 0)
|
type citedArticleAggregate struct {
|
||||||
for rows.Next() {
|
ArticleID int64
|
||||||
var articleID int64
|
ArticleTitle string
|
||||||
var title string
|
PublishPlatform string
|
||||||
var publishPlatform string
|
CitationCount int64
|
||||||
var citationCount int64
|
}
|
||||||
|
aggregates := make(map[int64]citedArticleAggregate)
|
||||||
var totalArticleCitationCount int64
|
var totalArticleCitationCount int64
|
||||||
if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount, &totalArticleCitationCount); scanErr != nil {
|
for rows.Next() {
|
||||||
|
var citedURL string
|
||||||
|
var normalizedURL string
|
||||||
|
if scanErr := rows.Scan(&citedURL, &normalizedURL); scanErr != nil {
|
||||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
|
||||||
}
|
}
|
||||||
|
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := aggregates[alias.ArticleID]
|
||||||
|
if item.ArticleID == 0 {
|
||||||
|
item.ArticleID = alias.ArticleID
|
||||||
|
item.ArticleTitle = alias.ArticleTitle
|
||||||
|
item.PublishPlatform = alias.PublishPlatform
|
||||||
|
}
|
||||||
|
item.CitationCount++
|
||||||
|
totalArticleCitationCount++
|
||||||
|
aggregates[alias.ArticleID] = item
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles")
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]MonitoringCitedArticle, 0, len(aggregates))
|
||||||
|
for _, item := range aggregates {
|
||||||
items = append(items, MonitoringCitedArticle{
|
items = append(items, MonitoringCitedArticle{
|
||||||
ArticleID: articleID,
|
ArticleID: item.ArticleID,
|
||||||
ArticleTitle: title,
|
ArticleTitle: item.ArticleTitle,
|
||||||
PublishPlatform: publishPlatform,
|
PublishPlatform: item.PublishPlatform,
|
||||||
CitationCount: citationCount,
|
CitationCount: item.CitationCount,
|
||||||
CitationRate: divideAsPointer(citationCount, totalSampleCount),
|
CitationRate: divideAsPointer(item.CitationCount, totalSampleCount),
|
||||||
SourceShare: divideAsPointer(citationCount, totalArticleCitationCount),
|
SourceShare: divideAsPointer(item.CitationCount, totalArticleCitationCount),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
sort.Slice(items, func(left, right int) bool {
|
||||||
|
if items[left].CitationCount != items[right].CitationCount {
|
||||||
|
return items[left].CitationCount > items[right].CitationCount
|
||||||
|
}
|
||||||
|
return items[left].ArticleID < items[right].ArticleID
|
||||||
|
})
|
||||||
|
if len(items) > 10 {
|
||||||
|
items = items[:10]
|
||||||
|
}
|
||||||
|
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
@@ -2923,6 +2945,11 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := s.monitoringPool.Query(ctx, `
|
rows, err := s.monitoringPool.Query(ctx, `
|
||||||
WITH filtered_citations AS (
|
WITH filtered_citations AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -2930,10 +2957,10 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
|||||||
cf.run_id,
|
cf.run_id,
|
||||||
cf.cited_url,
|
cf.cited_url,
|
||||||
cf.cited_title,
|
cf.cited_title,
|
||||||
|
cf.normalized_url,
|
||||||
cf.host,
|
cf.host,
|
||||||
cf.registrable_domain,
|
cf.registrable_domain,
|
||||||
cf.site_key,
|
cf.site_key,
|
||||||
cf.article_id,
|
|
||||||
cf.resolution_status,
|
cf.resolution_status,
|
||||||
cf.resolution_confidence
|
cf.resolution_confidence
|
||||||
FROM monitoring_citation_facts cf
|
FROM monitoring_citation_facts cf
|
||||||
@@ -2979,10 +3006,9 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
|||||||
fc.run_id,
|
fc.run_id,
|
||||||
fc.cited_url,
|
fc.cited_url,
|
||||||
fc.cited_title,
|
fc.cited_title,
|
||||||
|
fc.normalized_url,
|
||||||
COALESCE(dm.mapped_site_name, fc.site_key, fc.registrable_domain) AS site_name,
|
COALESCE(dm.mapped_site_name, fc.site_key, fc.registrable_domain) AS site_name,
|
||||||
COALESCE(dm.mapped_site_key, fc.site_key) AS site_key,
|
COALESCE(dm.mapped_site_key, fc.site_key) AS site_key,
|
||||||
fc.article_id,
|
|
||||||
alias.article_title_snapshot AS article_title,
|
|
||||||
fc.resolution_status,
|
fc.resolution_status,
|
||||||
fc.resolution_confidence
|
fc.resolution_confidence
|
||||||
FROM filtered_citations fc
|
FROM filtered_citations fc
|
||||||
@@ -2990,8 +3016,6 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
|||||||
ON dm.host = fc.host
|
ON dm.host = fc.host
|
||||||
AND dm.registrable_domain = fc.registrable_domain
|
AND dm.registrable_domain = fc.registrable_domain
|
||||||
AND dm.site_key = fc.site_key
|
AND dm.site_key = fc.site_key
|
||||||
LEFT JOIN monitoring_article_url_aliases alias
|
|
||||||
ON alias.tenant_id = $1 AND alias.article_id = fc.article_id
|
|
||||||
ORDER BY fc.run_id ASC, fc.id ASC
|
ORDER BY fc.run_id ASC, fc.id ASC
|
||||||
`, tenantID, runIDs)
|
`, tenantID, runIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -3003,21 +3027,26 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
|||||||
var runID int64
|
var runID int64
|
||||||
var citedURL string
|
var citedURL string
|
||||||
var citedTitle sql.NullString
|
var citedTitle sql.NullString
|
||||||
|
var normalizedURL string
|
||||||
var siteName string
|
var siteName string
|
||||||
var siteKey string
|
var siteKey string
|
||||||
var articleID sql.NullInt64
|
|
||||||
var articleTitle sql.NullString
|
|
||||||
var resolutionStatus string
|
var resolutionStatus string
|
||||||
var resolutionConfidence string
|
var resolutionConfidence string
|
||||||
if scanErr := rows.Scan(&runID, &citedURL, &citedTitle, &siteName, &siteKey, &articleID, &articleTitle, &resolutionStatus, &resolutionConfidence); scanErr != nil {
|
if scanErr := rows.Scan(&runID, &citedURL, &citedTitle, &normalizedURL, &siteName, &siteKey, &resolutionStatus, &resolutionConfidence); scanErr != nil {
|
||||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citations")
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citations")
|
||||||
}
|
}
|
||||||
siteName = repairMonitoringMojibake(siteName)
|
siteName = repairMonitoringMojibake(siteName)
|
||||||
if citedTitle.Valid {
|
if citedTitle.Valid {
|
||||||
citedTitle.String = repairMonitoringMojibake(citedTitle.String)
|
citedTitle.String = repairMonitoringMojibake(citedTitle.String)
|
||||||
}
|
}
|
||||||
if articleTitle.Valid {
|
var articleID *int64
|
||||||
articleTitle.String = repairMonitoringMojibake(articleTitle.String)
|
var articleTitle *string
|
||||||
|
if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
|
||||||
|
articleID = &alias.ArticleID
|
||||||
|
title := repairMonitoringMojibake(alias.ArticleTitle)
|
||||||
|
articleTitle = &title
|
||||||
|
resolutionStatus = "resolved"
|
||||||
|
resolutionConfidence = "high"
|
||||||
}
|
}
|
||||||
result[runID] = append(result[runID], MonitoringQuestionDetailCitation{
|
result[runID] = append(result[runID], MonitoringQuestionDetailCitation{
|
||||||
CitedURL: citedURL,
|
CitedURL: citedURL,
|
||||||
@@ -3025,12 +3054,15 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
|||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
SiteKey: siteKey,
|
SiteKey: siteKey,
|
||||||
FaviconURL: faviconURL(siteKey),
|
FaviconURL: faviconURL(siteKey),
|
||||||
ArticleID: nullableInt64Value(articleID),
|
ArticleID: articleID,
|
||||||
ArticleTitle: nullableStringValue(articleTitle),
|
ArticleTitle: articleTitle,
|
||||||
ResolutionStatus: resolutionStatus,
|
ResolutionStatus: resolutionStatus,
|
||||||
ResolutionConfidence: resolutionConfidence,
|
ResolutionConfidence: resolutionConfidence,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citations")
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -3040,15 +3072,21 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
|||||||
return []MonitoringQuestionCitationStats{}, nil
|
return []MonitoringQuestionCitationStats{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := s.monitoringPool.Query(ctx, `
|
rows, err := s.monitoringPool.Query(ctx, `
|
||||||
WITH filtered_facts AS (
|
WITH filtered_facts AS (
|
||||||
SELECT
|
SELECT
|
||||||
r.ai_platform_id,
|
r.ai_platform_id,
|
||||||
cf.id,
|
cf.id,
|
||||||
|
cf.cited_url,
|
||||||
|
cf.normalized_url,
|
||||||
cf.host,
|
cf.host,
|
||||||
cf.registrable_domain,
|
cf.registrable_domain,
|
||||||
cf.site_key,
|
cf.site_key
|
||||||
cf.article_id
|
|
||||||
FROM question_monitor_runs r
|
FROM question_monitor_runs r
|
||||||
JOIN monitoring_citation_facts cf
|
JOIN monitoring_citation_facts cf
|
||||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
||||||
@@ -3090,49 +3128,20 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
|||||||
ORDER BY LENGTH(mapping.registrable_domain) DESC, mapping.id DESC
|
ORDER BY LENGTH(mapping.registrable_domain) DESC, mapping.id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) dm ON TRUE
|
) dm ON TRUE
|
||||||
),
|
)
|
||||||
grouped AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
ff.ai_platform_id,
|
ff.ai_platform_id,
|
||||||
|
ff.cited_url,
|
||||||
|
ff.normalized_url,
|
||||||
COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain) AS site_name,
|
COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain) AS site_name,
|
||||||
COALESCE(dm.mapped_site_key, ff.site_key) AS site_key,
|
COALESCE(dm.mapped_site_key, ff.site_key) AS site_key,
|
||||||
COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain,
|
COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain
|
||||||
COUNT(ff.id) AS citation_count,
|
|
||||||
COUNT(*) FILTER (WHERE ff.article_id IS NOT NULL AND ff.ai_platform_id <> 'qwen') AS content_citation_count
|
|
||||||
FROM filtered_facts ff
|
FROM filtered_facts ff
|
||||||
LEFT JOIN domain_mappings dm
|
LEFT JOIN domain_mappings dm
|
||||||
ON dm.host = ff.host
|
ON dm.host = ff.host
|
||||||
AND dm.registrable_domain = ff.registrable_domain
|
AND dm.registrable_domain = ff.registrable_domain
|
||||||
AND dm.site_key = ff.site_key
|
AND dm.site_key = ff.site_key
|
||||||
GROUP BY
|
ORDER BY ff.ai_platform_id ASC, ff.id ASC
|
||||||
ff.ai_platform_id,
|
|
||||||
COALESCE(dm.mapped_site_name, ff.site_key, ff.registrable_domain),
|
|
||||||
COALESCE(dm.mapped_site_key, ff.site_key),
|
|
||||||
COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain)
|
|
||||||
),
|
|
||||||
totals AS (
|
|
||||||
SELECT
|
|
||||||
ai_platform_id,
|
|
||||||
SUM(citation_count) AS total_citation_count
|
|
||||||
FROM grouped
|
|
||||||
GROUP BY ai_platform_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
g.ai_platform_id,
|
|
||||||
g.site_name,
|
|
||||||
g.site_key,
|
|
||||||
g.site_domain,
|
|
||||||
g.citation_count,
|
|
||||||
CASE
|
|
||||||
WHEN t.total_citation_count > 0
|
|
||||||
THEN g.citation_count::double precision / t.total_citation_count::double precision
|
|
||||||
ELSE NULL
|
|
||||||
END AS citation_rate,
|
|
||||||
g.content_citation_count
|
|
||||||
FROM grouped g
|
|
||||||
JOIN totals t
|
|
||||||
ON t.ai_platform_id = g.ai_platform_id
|
|
||||||
ORDER BY g.ai_platform_id ASC, g.citation_count DESC, g.site_name ASC
|
|
||||||
`, tenantID, runIDs)
|
`, tenantID, runIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis")
|
return nil, response.ErrInternal(50041, "query_failed", "failed to load question citation analysis")
|
||||||
@@ -3148,12 +3157,12 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
|||||||
totals := make(map[string]int64)
|
totals := make(map[string]int64)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var platformID string
|
var platformID string
|
||||||
|
var citedURL string
|
||||||
|
var normalizedURL string
|
||||||
var siteName string
|
var siteName string
|
||||||
var siteKey string
|
var siteKey string
|
||||||
var siteDomain string
|
var siteDomain string
|
||||||
var citationCount int64
|
if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL, &siteName, &siteKey, &siteDomain); scanErr != nil {
|
||||||
var contentCitationCount int64
|
|
||||||
if scanErr := rows.Scan(&platformID, &siteName, &siteKey, &siteDomain, &citationCount, new(sql.NullFloat64), &contentCitationCount); scanErr != nil {
|
|
||||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis")
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis")
|
||||||
}
|
}
|
||||||
platformID = normalizeMonitoringPlatformID(platformID)
|
platformID = normalizeMonitoringPlatformID(platformID)
|
||||||
@@ -3174,10 +3183,17 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
|||||||
SiteDomain: siteDomain,
|
SiteDomain: siteDomain,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item.CitationCount += citationCount
|
item.CitationCount++
|
||||||
item.ContentCitationCount += contentCitationCount
|
if platformID != "qwen" {
|
||||||
|
if _, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
|
||||||
|
item.ContentCitationCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
aggregates[key] = item
|
aggregates[key] = item
|
||||||
totals[platformID] += citationCount
|
totals[platformID]++
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate question citation analysis")
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]MonitoringQuestionCitationStats, 0, len(aggregates))
|
items := make([]MonitoringQuestionCitationStats, 0, len(aggregates))
|
||||||
@@ -3206,40 +3222,23 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
|
|||||||
return []MonitoringQuestionContentCitation{}, nil
|
return []MonitoringQuestionContentCitation{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := s.monitoringPool.Query(ctx, `
|
rows, err := s.monitoringPool.Query(ctx, `
|
||||||
WITH platform_totals AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
r.ai_platform_id,
|
r.ai_platform_id,
|
||||||
COUNT(cf.id) AS total_citation_count
|
cf.cited_url,
|
||||||
|
cf.normalized_url
|
||||||
FROM question_monitor_runs r
|
FROM question_monitor_runs r
|
||||||
JOIN monitoring_citation_facts cf
|
JOIN monitoring_citation_facts cf
|
||||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
||||||
WHERE r.tenant_id = $1
|
WHERE r.tenant_id = $1
|
||||||
AND r.status = 'succeeded'
|
AND r.status = 'succeeded'
|
||||||
AND r.id = ANY($2)
|
|
||||||
GROUP BY r.ai_platform_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
cf.article_id,
|
|
||||||
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
|
|
||||||
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
|
|
||||||
r.ai_platform_id,
|
|
||||||
COUNT(*) AS citation_count,
|
|
||||||
MAX(pt.total_citation_count) AS total_citation_count
|
|
||||||
FROM question_monitor_runs r
|
|
||||||
JOIN monitoring_citation_facts cf
|
|
||||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
|
||||||
JOIN platform_totals pt
|
|
||||||
ON pt.ai_platform_id = r.ai_platform_id
|
|
||||||
LEFT JOIN monitoring_article_url_aliases alias
|
|
||||||
ON alias.tenant_id = cf.tenant_id AND alias.article_id = cf.article_id
|
|
||||||
WHERE r.tenant_id = $1
|
|
||||||
AND r.status = 'succeeded'
|
|
||||||
AND cf.article_id IS NOT NULL
|
|
||||||
AND r.ai_platform_id <> 'qwen'
|
AND r.ai_platform_id <> 'qwen'
|
||||||
AND r.id = ANY($2)
|
AND r.id = ANY($2)
|
||||||
GROUP BY cf.article_id, r.ai_platform_id
|
|
||||||
ORDER BY citation_count DESC, cf.article_id ASC
|
|
||||||
`, tenantID, runIDs)
|
`, tenantID, runIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations")
|
return nil, response.ErrInternal(50041, "query_failed", "failed to load content citations")
|
||||||
@@ -3254,37 +3253,40 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
|
|||||||
aggregates := make(map[contentCitationKey]MonitoringQuestionContentCitation)
|
aggregates := make(map[contentCitationKey]MonitoringQuestionContentCitation)
|
||||||
totals := make(map[string]int64)
|
totals := make(map[string]int64)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var articleID int64
|
|
||||||
var articleTitle string
|
|
||||||
var publishPlatform string
|
|
||||||
var platformID string
|
var platformID string
|
||||||
var citationCount int64
|
var citedURL string
|
||||||
var totalCitationCount int64
|
var normalizedURL string
|
||||||
if scanErr := rows.Scan(&articleID, &articleTitle, &publishPlatform, &platformID, &citationCount, &totalCitationCount); scanErr != nil {
|
if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL); scanErr != nil {
|
||||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations")
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations")
|
||||||
}
|
}
|
||||||
platformID = normalizeMonitoringPlatformID(platformID)
|
platformID = normalizeMonitoringPlatformID(platformID)
|
||||||
if platformID == "" {
|
if platformID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
totals[platformID]++
|
||||||
|
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
key := contentCitationKey{
|
key := contentCitationKey{
|
||||||
PlatformID: platformID,
|
PlatformID: platformID,
|
||||||
ArticleID: articleID,
|
ArticleID: alias.ArticleID,
|
||||||
}
|
}
|
||||||
item := aggregates[key]
|
item := aggregates[key]
|
||||||
if item.ArticleID == 0 {
|
if item.ArticleID == 0 {
|
||||||
item = MonitoringQuestionContentCitation{
|
item = MonitoringQuestionContentCitation{
|
||||||
ArticleID: articleID,
|
ArticleID: alias.ArticleID,
|
||||||
ArticleTitle: articleTitle,
|
ArticleTitle: alias.ArticleTitle,
|
||||||
PublishPlatform: publishPlatform,
|
PublishPlatform: alias.PublishPlatform,
|
||||||
AIPlatformID: platformID,
|
AIPlatformID: platformID,
|
||||||
PlatformName: platformDisplayName(platformID),
|
PlatformName: platformDisplayName(platformID),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item.CitationCount += citationCount
|
item.CitationCount++
|
||||||
aggregates[key] = item
|
aggregates[key] = item
|
||||||
totals[platformID] += citationCount
|
}
|
||||||
_ = totalCitationCount
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate content citations")
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]MonitoringQuestionContentCitation, 0, len(aggregates))
|
items := make([]MonitoringQuestionContentCitation, 0, len(aggregates))
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
|
|||||||
coverImageAssetID.Set = true
|
coverImageAssetID.Set = true
|
||||||
coverImageAssetID.Value = &rawCoverImageAssetID
|
coverImageAssetID.Value = &rawCoverImageAssetID
|
||||||
}
|
}
|
||||||
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, nil, coverAssetURL, coverImageAssetID)
|
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, coverAssetURL, coverImageAssetID)
|
||||||
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
tx, err := s.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func normalizePlatformIDs(platformIDs []string) []string {
|
func normalizePlatformIDs(platformIDs []string) []string {
|
||||||
@@ -31,52 +34,79 @@ func normalizePlatformIDs(platformIDs []string) []string {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
func parsePlatformIDs(value string) []string {
|
type scheduleAutoPublishPlatformQuerier interface {
|
||||||
if strings.TrimSpace(value) == "" {
|
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return normalizePlatformIDs(strings.Split(value, ","))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func serializePlatformIDs(platformIDs []string) string {
|
func loadScheduleAutoPublishPlatforms(
|
||||||
normalized := normalizePlatformIDs(platformIDs)
|
ctx context.Context,
|
||||||
if len(normalized) == 0 {
|
db scheduleAutoPublishPlatformQuerier,
|
||||||
return ""
|
tenantID int64,
|
||||||
}
|
inputParamsJSON []byte,
|
||||||
return strings.Join(normalized, ",")
|
) ([]string, error) {
|
||||||
}
|
if db == nil || tenantID <= 0 || len(inputParamsJSON) == 0 {
|
||||||
|
return []string{}, nil
|
||||||
func resolveArticlePlatforms(wizardStateJSON, inputParamsJSON []byte) []string {
|
|
||||||
if platforms := extractPlatformsFromJSONPayload(wizardStateJSON); len(platforms) > 0 {
|
|
||||||
return platforms
|
|
||||||
}
|
|
||||||
if platforms := extractPlatformsFromJSONPayload(inputParamsJSON); len(platforms) > 0 {
|
|
||||||
return platforms
|
|
||||||
}
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractPlatformsFromJSONPayload(raw []byte) []string {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload map[string]interface{}
|
var payload map[string]interface{}
|
||||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
if err := json.Unmarshal(inputParamsJSON, &payload); err != nil {
|
||||||
return nil
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
if !extractBool(payload, "schedule_auto_publish") {
|
||||||
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if platforms := normalizePlatformsFromValue(payload["platforms"]); len(platforms) > 0 {
|
accountIDs := normalizeSchedulePublishAccountIDs(extractStringList(payload["schedule_publish_account_ids"], 64))
|
||||||
return platforms
|
if len(accountIDs) == 0 {
|
||||||
}
|
return []string{}, nil
|
||||||
if platforms := normalizePlatformsFromValue(payload["target_platforms"]); len(platforms) > 0 {
|
|
||||||
return platforms
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if targetPlatform, ok := payload["target_platform"].(string); ok {
|
return loadPublishAccountPlatformsByIDs(ctx, db, tenantID, accountIDs)
|
||||||
return parsePlatformIDs(targetPlatform)
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
func loadPublishAccountPlatformsByIDs(
|
||||||
|
ctx context.Context,
|
||||||
|
db scheduleAutoPublishPlatformQuerier,
|
||||||
|
tenantID int64,
|
||||||
|
accountIDs []string,
|
||||||
|
) ([]string, error) {
|
||||||
|
accountIDs = normalizeSchedulePublishAccountIDs(accountIDs)
|
||||||
|
if db == nil || tenantID <= 0 || len(accountIDs) == 0 {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(ctx, `
|
||||||
|
SELECT DISTINCT platform_id
|
||||||
|
FROM platform_accounts
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND desktop_id::text = ANY($2::text[])
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY platform_id
|
||||||
|
`, tenantID, accountIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
platformIDs := make([]string, 0, len(accountIDs))
|
||||||
|
for rows.Next() {
|
||||||
|
var platformID string
|
||||||
|
if err := rows.Scan(&platformID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
platformIDs = append(platformIDs, platformID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return emptyStringSliceIfNil(normalizePlatformIDs(platformIDs)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyStringSliceIfNil(values []string) []string {
|
||||||
|
if values == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveArticleCoverAssetURL(wizardStateJSON []byte) *string {
|
func resolveArticleCoverAssetURL(wizardStateJSON []byte) *string {
|
||||||
@@ -146,51 +176,21 @@ func extractInt64FromJSONPayload(raw []byte, key string) (int64, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizePlatformsFromValue(raw interface{}) []string {
|
func mergeArticleWizardState(raw []byte, title string, coverAssetURL *string, coverImageAssetID NullableInt64Input) ([]byte, error) {
|
||||||
switch typed := raw.(type) {
|
|
||||||
case nil:
|
|
||||||
return nil
|
|
||||||
case string:
|
|
||||||
return parsePlatformIDs(typed)
|
|
||||||
case []string:
|
|
||||||
return normalizePlatformIDs(typed)
|
|
||||||
case []interface{}:
|
|
||||||
values := make([]string, 0, len(typed))
|
|
||||||
for _, item := range typed {
|
|
||||||
value, ok := item.(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
values = append(values, value)
|
|
||||||
}
|
|
||||||
return normalizePlatformIDs(values)
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeArticleWizardState(raw []byte, title string, platforms []string, coverAssetURL *string, coverImageAssetID NullableInt64Input) ([]byte, error) {
|
|
||||||
state := make(map[string]interface{})
|
state := make(map[string]interface{})
|
||||||
if len(raw) > 0 {
|
if len(raw) > 0 {
|
||||||
_ = json.Unmarshal(raw, &state)
|
_ = json.Unmarshal(raw, &state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delete(state, "platforms")
|
||||||
|
delete(state, "target_platforms")
|
||||||
|
delete(state, "target_platform")
|
||||||
|
|
||||||
trimmedTitle := strings.TrimSpace(title)
|
trimmedTitle := strings.TrimSpace(title)
|
||||||
if trimmedTitle != "" {
|
if trimmedTitle != "" {
|
||||||
state["title"] = trimmedTitle
|
state["title"] = trimmedTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
if platforms != nil {
|
|
||||||
normalized := normalizePlatformIDs(platforms)
|
|
||||||
if len(normalized) == 0 {
|
|
||||||
delete(state, "platforms")
|
|
||||||
delete(state, "target_platform")
|
|
||||||
} else {
|
|
||||||
state["platforms"] = normalized
|
|
||||||
state["target_platform"] = strings.Join(normalized, ",")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if coverAssetURL != nil {
|
if coverAssetURL != nil {
|
||||||
trimmedCoverURL := strings.TrimSpace(*coverAssetURL)
|
trimmedCoverURL := strings.TrimSpace(*coverAssetURL)
|
||||||
if trimmedCoverURL == "" {
|
if trimmedCoverURL == "" {
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ func TestMonitoringAliasOriginalURLUsesWangyihaoPublicURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMonitoringCitationURLMatchKeyCollapsesNeteaseHostPrefix(t *testing.T) {
|
||||||
|
left := monitoringCitationURLMatchKey("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html?from=test")
|
||||||
|
right := monitoringCitationURLMatchKey("https://www.163.com/dy/article/KRPRE8DJ0556MCR0.html")
|
||||||
|
|
||||||
|
const want = "163.com/dy/article/krpre8dj0556mcr0"
|
||||||
|
if left != want || right != want {
|
||||||
|
t.Fatalf("monitoringCitationURLMatchKey() = %q and %q; want %q", left, right, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizePublishStatusKeepsPartialSuccess(t *testing.T) {
|
func TestNormalizePublishStatusKeepsPartialSuccess(t *testing.T) {
|
||||||
if got := normalizePublishStatus("partial_success"); got != "partial_success" {
|
if got := normalizePublishStatus("partial_success"); got != "partial_success" {
|
||||||
t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got)
|
t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ type ScheduleTaskResponse struct {
|
|||||||
CronExpr string `json:"cron_expr"`
|
CronExpr string `json:"cron_expr"`
|
||||||
AutoPublish bool `json:"auto_publish"`
|
AutoPublish bool `json:"auto_publish"`
|
||||||
PublishAccountIDs []string `json:"publish_account_ids"`
|
PublishAccountIDs []string `json:"publish_account_ids"`
|
||||||
|
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
||||||
CoverAssetURL *string `json:"cover_asset_url"`
|
CoverAssetURL *string `json:"cover_asset_url"`
|
||||||
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
||||||
EnableWebSearch bool `json:"enable_web_search"`
|
EnableWebSearch bool `json:"enable_web_search"`
|
||||||
@@ -206,7 +207,7 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
|
|||||||
return &ScheduleTaskResponse{
|
return &ScheduleTaskResponse{
|
||||||
ID: id, WorkspaceID: &workspaceID, PromptRuleID: req.PromptRuleID, BrandID: req.BrandID,
|
ID: id, WorkspaceID: &workspaceID, PromptRuleID: req.PromptRuleID, BrandID: req.BrandID,
|
||||||
Name: req.Name, CronExpr: req.CronExpr, AutoPublish: publishConfig.AutoPublish,
|
Name: req.Name, CronExpr: req.CronExpr, AutoPublish: publishConfig.AutoPublish,
|
||||||
PublishAccountIDs: publishConfig.AccountIDs, CoverAssetURL: publishConfig.CoverAssetURL,
|
PublishAccountIDs: publishConfig.AccountIDs, AutoPublishPlatforms: publishConfig.PlatformIDs, CoverAssetURL: publishConfig.CoverAssetURL,
|
||||||
CoverImageAssetID: publishConfig.CoverImageAssetID, EnableWebSearch: enableWebSearch,
|
CoverImageAssetID: publishConfig.CoverImageAssetID, EnableWebSearch: enableWebSearch,
|
||||||
GenerateCount: generateCount,
|
GenerateCount: generateCount,
|
||||||
Status: status, CreatedAt: fmt.Sprintf("%v", createdAt),
|
Status: status, CreatedAt: fmt.Sprintf("%v", createdAt),
|
||||||
@@ -400,6 +401,9 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID in
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := s.populateScheduleAutoPublishPlatforms(ctx, tenantID, &item); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
return &ScheduleTaskListResponse{Items: items, Total: total}, nil
|
return &ScheduleTaskListResponse{Items: items, Total: total}, nil
|
||||||
@@ -425,9 +429,24 @@ func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenant
|
|||||||
}
|
}
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
if err := s.populateScheduleAutoPublishPlatforms(ctx, tenantID, &item); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
return &item, true, nil
|
return &item, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ScheduleTaskService) populateScheduleAutoPublishPlatforms(ctx context.Context, tenantID int64, item *ScheduleTaskResponse) error {
|
||||||
|
if item == nil || !item.AutoPublish {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
platformIDs, err := loadPublishAccountPlatformsByIDs(ctx, s.pool, tenantID, item.PublishAccountIDs)
|
||||||
|
if err != nil {
|
||||||
|
return response.ErrInternal(50010, "query_failed", "failed to resolve schedule auto publish platforms")
|
||||||
|
}
|
||||||
|
item.AutoPublishPlatforms = platformIDs
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func scanScheduleTaskResponse(scanner interface {
|
func scanScheduleTaskResponse(scanner interface {
|
||||||
Scan(dest ...interface{}) error
|
Scan(dest ...interface{}) error
|
||||||
}) (ScheduleTaskResponse, error) {
|
}) (ScheduleTaskResponse, error) {
|
||||||
@@ -480,6 +499,7 @@ func (s *ScheduleTaskService) ensurePromptRuleExists(ctx context.Context, tenant
|
|||||||
type scheduleAutoPublishConfig struct {
|
type scheduleAutoPublishConfig struct {
|
||||||
AutoPublish bool
|
AutoPublish bool
|
||||||
AccountIDs []string
|
AccountIDs []string
|
||||||
|
PlatformIDs []string
|
||||||
AccountIDsJSON []byte
|
AccountIDsJSON []byte
|
||||||
CoverAssetURL *string
|
CoverAssetURL *string
|
||||||
CoverImageAssetID *int64
|
CoverImageAssetID *int64
|
||||||
@@ -514,6 +534,7 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return config, err
|
return config, err
|
||||||
}
|
}
|
||||||
|
config.PlatformIDs = normalizePlatformIDs(platformIDs)
|
||||||
if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" {
|
if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" {
|
||||||
return config, response.ErrBadRequest(40025, "cover_required", "selected publish accounts require cover_asset_url")
|
return config, response.ErrBadRequest(40025, "cover_required", "selected publish accounts require cover_asset_url")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ type runtimePromptsConfig struct {
|
|||||||
PromptRuleSceneSupplementFormat string `yaml:"prompt_rule_scene_supplement_format"`
|
PromptRuleSceneSupplementFormat string `yaml:"prompt_rule_scene_supplement_format"`
|
||||||
PromptRuleToneSupplementFormat string `yaml:"prompt_rule_tone_supplement_format"`
|
PromptRuleToneSupplementFormat string `yaml:"prompt_rule_tone_supplement_format"`
|
||||||
PromptRuleWordCountSupplementFormat string `yaml:"prompt_rule_word_count_supplement_format"`
|
PromptRuleWordCountSupplementFormat string `yaml:"prompt_rule_word_count_supplement_format"`
|
||||||
PromptRuleTargetPlatformSupplementFormat string `yaml:"prompt_rule_target_platform_supplement_format"`
|
|
||||||
PromptRuleSupplementHeading string `yaml:"prompt_rule_supplement_heading"`
|
PromptRuleSupplementHeading string `yaml:"prompt_rule_supplement_heading"`
|
||||||
PromptRuleOutputRequirementsSection string `yaml:"prompt_rule_output_requirements_section"`
|
PromptRuleOutputRequirementsSection string `yaml:"prompt_rule_output_requirements_section"`
|
||||||
ArticleImitationPromptTemplate string `yaml:"article_imitation_prompt_template"`
|
ArticleImitationPromptTemplate string `yaml:"article_imitation_prompt_template"`
|
||||||
|
|||||||
@@ -121,13 +121,6 @@ func PromptRuleWordCountSupplement(wordCount int) string {
|
|||||||
return fmt.Sprintf(trimPrompt(loadRuntimePrompts().PromptRuleWordCountSupplementFormat), wordCount)
|
return fmt.Sprintf(trimPrompt(loadRuntimePrompts().PromptRuleWordCountSupplementFormat), wordCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func PromptRuleTargetPlatformSupplement(target string) string {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
trimPrompt(loadRuntimePrompts().PromptRuleTargetPlatformSupplementFormat),
|
|
||||||
strings.ReplaceAll(target, ",", "、"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func PromptRuleSupplementSection(items []string) string {
|
func PromptRuleSupplementSection(items []string) string {
|
||||||
return trimPrompt(loadRuntimePrompts().PromptRuleSupplementHeading) + "\n- " + strings.Join(items, "\n- ")
|
return trimPrompt(loadRuntimePrompts().PromptRuleSupplementHeading) + "\n- " + strings.Join(items, "\n- ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,21 @@ func (h *MonitoringHandler) CitationSummary(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days)
|
brandID, err := parseOptionalInt64(c.Query("brand_id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40031, "invalid_brand_id", "brand_id must be a number"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keywordID, err := parseOptionalInt64Pointer(c.Query("keyword_id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40031, "invalid_keyword_id", "keyword_id must be a number"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
|
||||||
|
|
||||||
|
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, c.Query("business_date"), aiPlatformID)
|
||||||
if svcErr != nil {
|
if svcErr != nil {
|
||||||
response.Error(c, svcErr)
|
response.Error(c, svcErr)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
|
|
||||||
if swagger.EnabledForMode(app.Config.Server.Mode) {
|
if swagger.EnabledForMode(app.Config.Server.Mode) {
|
||||||
swagger.Register(app.Engine, swagger.Config{
|
swagger.Register(app.Engine, swagger.Config{
|
||||||
Title: "Geo Rankly Admin API",
|
Title: "省心推 Admin API",
|
||||||
Version: "dev",
|
Version: "dev",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
-- Legacy article target platform JSON keys are intentionally not restored.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
UPDATE articles
|
||||||
|
SET wizard_state_json = wizard_state_json - 'platforms' - 'target_platforms' - 'target_platform',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE wizard_state_json IS NOT NULL
|
||||||
|
AND wizard_state_json ?| ARRAY['platforms', 'target_platforms', 'target_platform'];
|
||||||
|
|
||||||
|
UPDATE generation_tasks
|
||||||
|
SET input_params_json = input_params_json - 'platforms' - 'target_platforms' - 'target_platform',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE input_params_json IS NOT NULL
|
||||||
|
AND input_params_json ?| ARRAY['platforms', 'target_platforms', 'target_platform'];
|
||||||
Reference in New Issue
Block a user