Compare commits
7 Commits
0f4310c345
...
9727077dfa
| Author | SHA1 | Date | |
|---|---|---|---|
| 9727077dfa | |||
| b345ee26e4 | |||
| 2436f50c1f | |||
| b18a562b32 | |||
| 5a613abc33 | |||
| dca36ed1f6 | |||
| 490c6c759d |
@@ -6,7 +6,7 @@
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<title>GEO 工具平台</title>
|
||||
<title>省心推</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
<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 { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import ImageUploadProgressOverlay from "@/components/ImageUploadProgressOverlay.vue";
|
||||
|
||||
const { locale } = useI18n();
|
||||
const antLocale = computed(() => (locale.value === "en-US" ? enUS : zhCN));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider
|
||||
:locale="antLocale"
|
||||
:locale="zhCN"
|
||||
:theme="{
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
|
||||
@@ -116,7 +116,7 @@ const deleteMutation = useMutation({
|
||||
const columns = computed<TableColumnsType<ArticleListItem>>(() => [
|
||||
{ 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.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.publishStatus"), dataIndex: "publish_status", key: "publish_status", width: 128 },
|
||||
{ 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'">
|
||||
{{ record.prompt_rule_name || "--" }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'platforms'">
|
||||
{{ formatPublishPlatformList(record.platforms) }}
|
||||
<template v-else-if="column.key === 'auto_publish_platforms'">
|
||||
{{ formatPublishPlatformList(record.auto_publish_platforms) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'generate_status'">
|
||||
<ArticleGenerateStatus :status="record.generate_status" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { message } from "ant-design-vue";
|
||||
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") }}
|
||||
</p>
|
||||
|
||||
<div v-if="effectiveScheduleCoverEnabled" class="generate-task-drawer__cover-row">
|
||||
<button type="button" class="generate-task-drawer__cover" @click="coverPickerOpen = true">
|
||||
<template v-if="form.coverAssetUrl">
|
||||
<img :src="form.coverAssetUrl" alt="cover preview" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="generate-task-drawer__cover-plus">+</span>
|
||||
<strong>{{ t("custom.task.coverUpload") }}</strong>
|
||||
<small>{{ t("custom.task.scheduleCoverUploadHint") }}</small>
|
||||
</template>
|
||||
</button>
|
||||
<div v-if="effectiveScheduleCoverEnabled" class="generate-task-drawer__cover-body">
|
||||
<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">
|
||||
<img :src="form.coverAssetUrl" alt="cover preview" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="generate-task-drawer__cover-plus">+</span>
|
||||
<span>{{ t("custom.task.coverUpload") }}</span>
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<div v-if="form.coverAssetUrl" class="generate-task-drawer__cover-meta">
|
||||
<strong>{{ form.coverFileName || t("custom.task.cover") }}</strong>
|
||||
<a-button size="small" @click="coverPickerOpen = true">
|
||||
{{ t("article.editor.coverReplace") }}
|
||||
</a-button>
|
||||
<a-button size="small" danger @click="handleRemoveScheduleCover">
|
||||
{{ t("article.editor.coverRemove") }}
|
||||
</a-button>
|
||||
<button
|
||||
v-if="form.coverAssetUrl"
|
||||
type="button"
|
||||
class="generate-task-drawer__cover-remove"
|
||||
:aria-label="t('article.editor.coverRemove')"
|
||||
:title="t('article.editor.coverRemove')"
|
||||
@click.stop="handleRemoveScheduleCover"
|
||||
>
|
||||
<MinusCircleFilled />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -806,8 +808,7 @@ function padTime(value: string): string {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.generate-task-drawer__account-copy strong,
|
||||
.generate-task-drawer__cover-meta strong {
|
||||
.generate-task-drawer__account-copy strong {
|
||||
overflow: hidden;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
@@ -846,7 +847,7 @@ function padTime(value: string): string {
|
||||
.generate-task-drawer__cover-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -875,71 +876,72 @@ function padTime(value: string): string {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.generate-task-drawer__cover-row {
|
||||
.generate-task-drawer__cover-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 228px;
|
||||
min-height: 172px;
|
||||
width: 100%;
|
||||
min-height: 124px;
|
||||
padding: 0;
|
||||
border: 1px dashed #cfd9e8;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: #fbfcff;
|
||||
color: #1f2937;
|
||||
background: #f9fafb;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.generate-task-drawer__cover:hover {
|
||||
border-color: #9db7ff;
|
||||
background: #f7faff;
|
||||
.generate-task-drawer__cover-preview:hover {
|
||||
border-color: #6366f1;
|
||||
background: #f5f3ff;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.generate-task-drawer__cover img {
|
||||
.generate-task-drawer__cover-preview img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 172px;
|
||||
height: 124px;
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 999px;
|
||||
background: #eef4ff;
|
||||
color: #355dff;
|
||||
font-size: 28px;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.generate-task-drawer__cover-meta {
|
||||
display: flex;
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
.generate-task-drawer__cover-remove {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
|
||||
@@ -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.instant.executionStatus"), dataIndex: "status", key: "status", width: 140 },
|
||||
{ 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.instant.createdTime"), dataIndex: "created_at", key: "created_at", width: 170 },
|
||||
{ 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'">
|
||||
{{ formatDateTime(record.execution_time) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'platforms'">
|
||||
{{ formatPublishPlatformList(record.platforms) }}
|
||||
<template v-else-if="column.key === 'auto_publish_platforms'">
|
||||
{{ formatPublishPlatformList(record.auto_publish_platforms) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'generate_count'">
|
||||
{{ record.generate_count || 1 }}
|
||||
|
||||
@@ -21,9 +21,6 @@ import {
|
||||
publishStateLabel,
|
||||
type PublishAccountCard,
|
||||
} from "@/lib/publish-account-cards";
|
||||
import {
|
||||
normalizePublishPlatformIds,
|
||||
} from "@/lib/publish-platforms";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
@@ -93,10 +90,8 @@ watch(
|
||||
|
||||
const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? []));
|
||||
|
||||
const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuery.data.value?.platforms ?? []));
|
||||
|
||||
const accountCards = computed<PublishAccountCard[]>(() =>
|
||||
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value, articlePlatformIds.value),
|
||||
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value),
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -137,10 +132,7 @@ watchEffect(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredPlatforms = new Set(articlePlatformIds.value);
|
||||
selectedAccountIds.value = accountCards.value
|
||||
.filter((card) => card.selectable && preferredPlatforms.has(card.platformId))
|
||||
.map((card) => card.id);
|
||||
selectedAccountIds.value = [];
|
||||
selectionHydrated.value = true;
|
||||
});
|
||||
|
||||
@@ -237,7 +229,6 @@ async function persistCoverIfNeeded(detail: ArticleDetail): Promise<ArticleDetai
|
||||
return articlesApi.update(detail.id, {
|
||||
title: detail.title?.trim() || t("article.untitled"),
|
||||
markdown_content: detail.markdown_content ?? "",
|
||||
platforms: normalizePublishPlatformIds(detail.platforms ?? []),
|
||||
cover_asset_url: nextUrl || null,
|
||||
cover_image_asset_id: coverImageAssetId.value,
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import ScheduleTaskModal from "@/components/ScheduleTaskModal.vue";
|
||||
import { promptRulesApi, schedulesApi } from "@/lib/api";
|
||||
import { formatCronExecutionTime, formatDateTime } from "@/lib/display";
|
||||
import { formatError } from "@/lib/errors";
|
||||
import { formatPublishPlatformList } from "@/lib/publish-platforms";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
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.instant.executionStatus"), dataIndex: "status", key: "status", width: 140 },
|
||||
{ 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.instant.createdTime"), dataIndex: "created_at", key: "created_at", width: 170 },
|
||||
{ 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'">
|
||||
{{ formatCronExecutionTime(record.cron_expr) }}
|
||||
</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'">
|
||||
{{
|
||||
record.auto_publish
|
||||
? t("custom.schedule.autoPublishAccountCount", { count: record.publish_account_ids?.length ?? 0 })
|
||||
? formatPublishPlatformList(record.auto_publish_platforms)
|
||||
: t("custom.schedule.manualPublish")
|
||||
}}
|
||||
</a-tag>
|
||||
|
||||
@@ -1,44 +1,14 @@
|
||||
import { createI18n } from "vue-i18n";
|
||||
|
||||
import enUS from "./messages/en-US";
|
||||
import zhCN from "./messages/zh-CN";
|
||||
|
||||
export const LOCALE_STORAGE_KEY = "geo.admin.locale";
|
||||
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";
|
||||
}
|
||||
const APP_LOCALE = "zh-CN";
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: resolveLocale(),
|
||||
fallbackLocale: "en-US",
|
||||
locale: APP_LOCALE,
|
||||
fallbackLocale: APP_LOCALE,
|
||||
messages: {
|
||||
"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",
|
||||
citationRanking: "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",
|
||||
citationWindow30: "Last 30 days",
|
||||
citationWindowLabel: "Last {days} days",
|
||||
@@ -1290,9 +1290,9 @@ const enUS = {
|
||||
name: "Task name",
|
||||
rule: "Prompt rule",
|
||||
cron: "Frequency",
|
||||
autoPublishPlatforms: "Auto publish platforms",
|
||||
autoPublish: "Auto publish",
|
||||
manualPublish: "Manual publish",
|
||||
autoPublishAccountCount: "Auto · {count} accounts",
|
||||
nextRun: "Next run",
|
||||
startAt: "Start time",
|
||||
endAt: "End time",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const zhCN = {
|
||||
app: {
|
||||
name: "GEO工具平台",
|
||||
loginIntro: "面向企业的品牌资产管理与内容生产协作系统",
|
||||
name: "省心推",
|
||||
loginIntro: "面向企业和个人的品牌资产管理与内容生产协作系统",
|
||||
},
|
||||
locale: {
|
||||
"zh-CN": "中文简体",
|
||||
@@ -153,7 +153,7 @@ const zhCN = {
|
||||
route: {
|
||||
login: {
|
||||
title: "欢迎回来",
|
||||
description: "使用真实租户账号登录 GEO 工具平台。",
|
||||
description: "登录省心推,开启品牌内容创作之旅",
|
||||
},
|
||||
workspace: {
|
||||
title: "工作台",
|
||||
@@ -433,7 +433,7 @@ const zhCN = {
|
||||
backToQuestions: "返回问题列表",
|
||||
citationRanking: "Citation Ranking",
|
||||
citationRankingTitle: "引用排行",
|
||||
citationRankingHint: "按主体域名归因 SaaS 发文在模型引用来源中的出现次数与占比。",
|
||||
citationRankingHint: "按发布外链主体归因 SaaS 发文在模型引用来源中的出现次数与占比。",
|
||||
citationWindow7: "近 7 天",
|
||||
citationWindow30: "近 30 天",
|
||||
citationWindowLabel: "近 {days} 天",
|
||||
@@ -1300,9 +1300,9 @@ const zhCN = {
|
||||
name: "任务名称",
|
||||
rule: "关联规则",
|
||||
cron: "执行频率",
|
||||
autoPublishPlatforms: "自动发布平台",
|
||||
autoPublish: "自动发布",
|
||||
manualPublish: "手动发布",
|
||||
autoPublishAccountCount: "自动 · {count} 个账号",
|
||||
nextRun: "下次执行",
|
||||
startAt: "开始时间",
|
||||
endAt: "结束时间",
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { workspaceApi } from "@/lib/api";
|
||||
import { type AppLocale, setAppLocale } from "@/i18n";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -86,22 +85,6 @@ type NavSection = {
|
||||
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 base = [
|
||||
{
|
||||
@@ -220,10 +203,6 @@ function go(path: string): void {
|
||||
void router.push(path);
|
||||
}
|
||||
|
||||
function handleLocaleChange({ key }: { key: any }): void {
|
||||
currentLocale.value = key as AppLocale;
|
||||
}
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
await authStore.logout();
|
||||
await router.replace("/login");
|
||||
@@ -298,22 +277,6 @@ async function handleLogout(): Promise<void> {
|
||||
</nav>
|
||||
|
||||
<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 -->
|
||||
<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>
|
||||
@@ -390,6 +353,7 @@ async function handleLogout(): Promise<void> {
|
||||
|
||||
.admin-main-layout {
|
||||
margin-left: 250px;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.admin-main-layout--blocked {
|
||||
@@ -424,7 +388,7 @@ async function handleLogout(): Promise<void> {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 1;
|
||||
z-index: 100;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
@@ -487,37 +451,6 @@ async function handleLogout(): Promise<void> {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -620,6 +553,8 @@ async function handleLogout(): Promise<void> {
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
margin: 24px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
@@ -1078,6 +1078,10 @@ export const monitoringApi = {
|
||||
},
|
||||
citationSummary(params: {
|
||||
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", {
|
||||
params,
|
||||
|
||||
@@ -140,7 +140,6 @@ html, body, #app {
|
||||
|
||||
.ant-layout-header {
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,0.08); /* slight shadow for topbar */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
|
||||
@@ -161,7 +161,7 @@ const recentTemplateArticles = computed<ArticleListItem[]>(() =>
|
||||
prompt_rule_id: null,
|
||||
prompt_rule_name: null,
|
||||
generation_mode: article.generation_mode,
|
||||
platforms: [],
|
||||
auto_publish_platforms: [],
|
||||
generate_status: article.generate_status,
|
||||
publish_status: article.publish_status,
|
||||
title: article.title,
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
MonitoringPlatformAuthorizationStatus,
|
||||
} 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 { useRoute, useRouter } from "vue-router";
|
||||
|
||||
@@ -251,10 +251,19 @@ const citationSummaryQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
"tracking",
|
||||
"citation-summary",
|
||||
selectedBrandId.value,
|
||||
selectedKeywordId.value,
|
||||
selectedPlatformId.value,
|
||||
selectedBusinessDate.value,
|
||||
selectedCitationWindowDays.value,
|
||||
]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () =>
|
||||
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,
|
||||
}),
|
||||
});
|
||||
@@ -495,25 +504,72 @@ const visibleCitedArticles = computed(() => {
|
||||
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);
|
||||
|
||||
if (!segments.length) {
|
||||
return {
|
||||
background: "linear-gradient(180deg, #eef4ff 0%, #e5ecff 100%)",
|
||||
};
|
||||
return [];
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
const gradientStops = segments.map((item) => {
|
||||
const next = offset + item.share * 360;
|
||||
const stop = `${item.color} ${offset.toFixed(2)}deg ${next.toFixed(2)}deg`;
|
||||
offset = next;
|
||||
return stop;
|
||||
});
|
||||
const cx = 100;
|
||||
const cy = 100;
|
||||
const r = 100;
|
||||
|
||||
let offset = 0;
|
||||
|
||||
return segments.map((item) => {
|
||||
const share = item.share;
|
||||
|
||||
if (share === 1) {
|
||||
return {
|
||||
...item,
|
||||
isFull: true,
|
||||
path: "",
|
||||
};
|
||||
}
|
||||
|
||||
const startAngle = offset * 2 * Math.PI - Math.PI / 2;
|
||||
const endAngle = (offset + share) * 2 * Math.PI - Math.PI / 2;
|
||||
|
||||
const x1 = cx + r * Math.cos(startAngle);
|
||||
const y1 = cy + r * Math.sin(startAngle);
|
||||
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 {
|
||||
...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: `conic-gradient(${gradientStops.join(", ")})`,
|
||||
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-visual">
|
||||
<div class="citation-ranking-visual__canvas">
|
||||
<div class="citation-ranking-visual__circle" :style="citationChartStyle" />
|
||||
<div class="citation-ranking-visual__canvas" @mouseleave="hoveredSlice = null" @mousemove="onPieMouseMove">
|
||||
<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 v-if="citationRankingRows.length" class="citation-ranking-legend">
|
||||
@@ -1390,12 +1476,74 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
}
|
||||
|
||||
.citation-ranking-visual__canvas {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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 {
|
||||
width: min(32vw, 420px);
|
||||
height: min(32vw, 420px);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
appId: com.geo.rankly.desktop
|
||||
productName: GEO Rankly Desktop
|
||||
copyright: Copyright © 2026 GEO Rankly
|
||||
productName: 省心推
|
||||
copyright: Copyright © 2026 shengxintui.com. All Rights Reserved.
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
"name": "@geo/desktop-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "GEO Rankly Desktop — account health, monitor scheduler, and publish dispatch client.",
|
||||
"description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。",
|
||||
"author": {
|
||||
"name": "GEO Rankly",
|
||||
"email": "support@geo-rankly.local"
|
||||
"name": "Liang Xu",
|
||||
"email": "liangxu@qq.com",
|
||||
"url": "https://shengxintui.com"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "out/main/bootstrap.cjs",
|
||||
|
||||
@@ -340,7 +340,7 @@ async function createAppWindow(mode: WindowMode): Promise<ElectronBrowserWindow>
|
||||
minWidth: initial.minWidth,
|
||||
minHeight: initial.minHeight,
|
||||
show: false,
|
||||
title: "GEO Rankly Desktop",
|
||||
title: "省心推",
|
||||
icon: createDesktopHealthIcon("normal"),
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 14 },
|
||||
@@ -414,7 +414,7 @@ async function createSettingsWindow(): Promise<ElectronBrowserWindow> {
|
||||
minWidth: initial.minWidth,
|
||||
minHeight: initial.minHeight,
|
||||
show: false,
|
||||
title: "GEO Rankly Desktop 设置",
|
||||
title: "省心推设置",
|
||||
icon: createDesktopHealthIcon("normal"),
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 14, y: 15 },
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("playwright cdp target pruning", () => {
|
||||
expect(isReclaimableHiddenPlaywrightTarget({
|
||||
id: "renderer",
|
||||
type: "page",
|
||||
title: "GEO Rankly Desktop",
|
||||
title: "省心推 - 账号健康监控与内容发布平台",
|
||||
url: "http://localhost:5173/",
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
@@ -165,7 +165,7 @@ function createLiveRuntimeSnapshot(controller: RuntimeControllerSnapshot) {
|
||||
return {
|
||||
generatedAt: now,
|
||||
app: {
|
||||
name: "GEO Rankly Desktop",
|
||||
name: "省心推",
|
||||
version: app.getVersion(),
|
||||
channel: process.env.NODE_ENV === "development" ? "dev" : "release",
|
||||
platform: process.platform,
|
||||
|
||||
@@ -57,7 +57,7 @@ export function updateTrayIssueIndicator(issueCount: number): void {
|
||||
|
||||
tray.setImage(createTrayIcon(hasIssues ? "danger" : "normal"));
|
||||
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 {
|
||||
|
||||
@@ -88,7 +88,7 @@ function openSettingsWindow() {
|
||||
</div>
|
||||
<div>
|
||||
<p class="eyebrow">Workspace Cockpit</p>
|
||||
<h1>GEO Rankly Desktop</h1>
|
||||
<h1>省心推</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
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"
|
||||
/>
|
||||
<title>GEO Rankly Desktop</title>
|
||||
<title>省心推</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -105,7 +105,7 @@ onBeforeUnmount(() => {
|
||||
<div class="brand-logo">
|
||||
<span class="brand-core"></span>
|
||||
</div>
|
||||
<h1 class="brand-name">GEO Rankly</h1>
|
||||
<h1 class="brand-name">省心推</h1>
|
||||
</header>
|
||||
|
||||
<form class="login-form" @submit.prevent="submitLogin">
|
||||
|
||||
@@ -210,7 +210,6 @@ runtime:
|
||||
prompt_rule_scene_supplement_format: "适用场景:%s"
|
||||
prompt_rule_tone_supplement_format: "默认语气:%s"
|
||||
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
|
||||
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
|
||||
prompt_rule_supplement_heading: "补充要求:"
|
||||
prompt_rule_output_requirements_section: |
|
||||
输出要求:
|
||||
|
||||
@@ -210,7 +210,6 @@ runtime:
|
||||
prompt_rule_scene_supplement_format: "适用场景:%s"
|
||||
prompt_rule_tone_supplement_format: "默认语气:%s"
|
||||
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
|
||||
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
|
||||
prompt_rule_supplement_heading: "补充要求:"
|
||||
prompt_rule_output_requirements_section: |
|
||||
输出要求:
|
||||
|
||||
@@ -557,7 +557,7 @@ export interface ArticleListItem {
|
||||
prompt_rule_id: number | null;
|
||||
prompt_rule_name: string | null;
|
||||
generation_mode: string | null;
|
||||
platforms: string[];
|
||||
auto_publish_platforms: string[];
|
||||
generate_status: string;
|
||||
publish_status: string;
|
||||
title: string | null;
|
||||
@@ -582,7 +582,7 @@ export interface ArticleDetail {
|
||||
template_id: number | null;
|
||||
template_name: string | null;
|
||||
generation_mode: string | null;
|
||||
platforms: string[];
|
||||
auto_publish_platforms: string[];
|
||||
cover_asset_url: string | null;
|
||||
cover_image_asset_id: number | null;
|
||||
generate_status: string;
|
||||
@@ -632,7 +632,6 @@ export interface GenerateImitationResponse {
|
||||
export interface UpdateArticleRequest {
|
||||
title: string;
|
||||
markdown_content: string;
|
||||
platforms?: string[];
|
||||
cover_asset_url?: string | null;
|
||||
referenced_image_asset_ids?: number[];
|
||||
cover_image_asset_id?: number | null;
|
||||
@@ -1448,6 +1447,7 @@ export interface ScheduleTask {
|
||||
cron_expr: string;
|
||||
auto_publish: boolean;
|
||||
publish_account_ids: string[];
|
||||
auto_publish_platforms: string[];
|
||||
cover_asset_url: string | null;
|
||||
cover_image_asset_id: number | null;
|
||||
enable_web_search: boolean;
|
||||
@@ -1534,7 +1534,7 @@ export interface InstantTaskItem {
|
||||
name: string;
|
||||
status: string;
|
||||
execution_time: string | null;
|
||||
platforms: string[];
|
||||
auto_publish_platforms: string[];
|
||||
generate_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -210,7 +210,6 @@ runtime:
|
||||
prompt_rule_scene_supplement_format: "适用场景:%s"
|
||||
prompt_rule_tone_supplement_format: "默认语气:%s"
|
||||
prompt_rule_word_count_supplement_format: "建议字数:%d 字左右"
|
||||
prompt_rule_target_platform_supplement_format: "目标发布平台:%s"
|
||||
prompt_rule_supplement_heading: "补充要求:"
|
||||
prompt_rule_output_requirements_section: |
|
||||
输出要求:
|
||||
|
||||
@@ -123,7 +123,7 @@ func BuildOpenAPI(routes gin.RoutesInfo, cfg Config) map[string]any {
|
||||
|
||||
func withDefaults(cfg Config) Config {
|
||||
if strings.TrimSpace(cfg.Title) == "" {
|
||||
cfg.Title = "Geo Rankly Tenant API"
|
||||
cfg.Title = "省心推 客户 API"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Version) == "" {
|
||||
cfg.Version = "dev"
|
||||
@@ -557,7 +557,7 @@ const swaggerIndexHTML = `<!doctype html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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" />
|
||||
<style>
|
||||
body {
|
||||
|
||||
@@ -115,7 +115,7 @@ type ArticleListItem struct {
|
||||
PromptRuleID *int64 `json:"prompt_rule_id"`
|
||||
PromptRuleName *string `json:"prompt_rule_name"`
|
||||
GenerationMode *string `json:"generation_mode"`
|
||||
Platforms []string `json:"platforms"`
|
||||
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
||||
GenerateStatus string `json:"generate_status"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
Title *string `json:"title"`
|
||||
@@ -147,7 +147,7 @@ type ArticleDetailResponse struct {
|
||||
TemplateID *int64 `json:"template_id"`
|
||||
TemplateName *string `json:"template_name"`
|
||||
GenerationMode *string `json:"generation_mode"`
|
||||
Platforms []string `json:"platforms"`
|
||||
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
||||
GenerateStatus string `json:"generate_status"`
|
||||
@@ -355,7 +355,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
||||
}
|
||||
item.SourceType = dbSourceType
|
||||
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)
|
||||
items = append(items, item)
|
||||
}
|
||||
@@ -407,7 +411,11 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
|
||||
_ = json.Unmarshal(wizardStateJSON, &item.WizardState)
|
||||
}
|
||||
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.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
||||
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
|
||||
@@ -495,7 +503,6 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (
|
||||
type UpdateArticleRequest struct {
|
||||
Title string `json:"title"`
|
||||
MarkdownContent string `json:"markdown_content"`
|
||||
Platforms []string `json:"platforms"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
ReferencedImageAssetIDs []int64 `json:"referenced_image_asset_ids"`
|
||||
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(
|
||||
wizardStateJSON,
|
||||
title,
|
||||
req.Platforms,
|
||||
req.CoverAssetURL,
|
||||
resolvedCoverImageAssetInput,
|
||||
)
|
||||
|
||||
@@ -31,16 +31,16 @@ type InstantTaskListParams struct {
|
||||
}
|
||||
|
||||
type InstantTaskResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ArticleID *int64 `json:"article_id"`
|
||||
PromptRuleID *int64 `json:"prompt_rule_id"`
|
||||
PromptRuleName *string `json:"prompt_rule_name"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
ExecutionTime *string `json:"execution_time"`
|
||||
Platforms []string `json:"platforms"`
|
||||
GenerateCount int `json:"generate_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
ArticleID *int64 `json:"article_id"`
|
||||
PromptRuleID *int64 `json:"prompt_rule_id"`
|
||||
PromptRuleName *string `json:"prompt_rule_name"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
ExecutionTime *string `json:"execution_time"`
|
||||
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
||||
GenerateCount int `json:"generate_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type InstantTaskListResponse struct {
|
||||
@@ -123,7 +123,11 @@ func (s *InstantTaskService) List(ctx context.Context, params InstantTaskListPar
|
||||
|
||||
item.ExecutionTime = stringPtrFromDBValue(executionAt)
|
||||
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
|
||||
|
||||
if len(inputParamsJSON) > 0 {
|
||||
|
||||
@@ -284,6 +284,7 @@ type monitoringAliasResolution struct {
|
||||
|
||||
type monitoringAliasLookupInput struct {
|
||||
NormalizedURL string
|
||||
MatchKeys []string
|
||||
RegistrableDomain string
|
||||
SiteKey string
|
||||
Host string
|
||||
@@ -293,6 +294,7 @@ type monitoringAliasLookupInput struct {
|
||||
|
||||
type monitoringAliasCandidate struct {
|
||||
NormalizedURL string
|
||||
MatchKeys []string
|
||||
ArticleID *int64
|
||||
PublishRecordID *int64
|
||||
RegistrableDomain string
|
||||
@@ -2444,6 +2446,7 @@ func (s *MonitoringCallbackService) buildCitationFacts(ctx context.Context, tx p
|
||||
}
|
||||
lookupInputs = append(lookupInputs, monitoringAliasLookupInput{
|
||||
NormalizedURL: key,
|
||||
MatchKeys: monitoringCitationCandidateKeys(item.URL, key),
|
||||
RegistrableDomain: strings.ToLower(strings.TrimSpace(registrableDomain)),
|
||||
SiteKey: strings.ToLower(strings.TrimSpace(siteKey)),
|
||||
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.LastPathSegment = strings.TrimSpace(nullableStringText(lastPathSegment))
|
||||
candidate.TitleKey = normalizeMonitoringAliasTitle(nullableStringText(articleTitle))
|
||||
candidate.MatchKeys = monitoringCitationCandidateKeys(candidate.NormalizedURL, candidate.NormalizedURL)
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -2567,11 +2571,7 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
|
||||
bestScore := -1
|
||||
bestAmbiguous := false
|
||||
var best monitoringAliasCandidate
|
||||
domainMatchedArticleIDs := map[int64]struct{}{}
|
||||
for _, candidate := range candidates {
|
||||
if monitoringAliasDomainMatches(input, candidate) && candidate.ArticleID != nil {
|
||||
domainMatchedArticleIDs[*candidate.ArticleID] = struct{}{}
|
||||
}
|
||||
score := scoreMonitoringAliasCandidate(input, candidate)
|
||||
if score < 0 {
|
||||
continue
|
||||
@@ -2586,27 +2586,16 @@ func (s *MonitoringCallbackService) loadAliasResolutions(ctx context.Context, tx
|
||||
bestAmbiguous = true
|
||||
}
|
||||
}
|
||||
if bestScore == 40 && len(domainMatchedArticleIDs) == 1 {
|
||||
bestScore = 60
|
||||
}
|
||||
|
||||
if bestScore < 60 || bestAmbiguous {
|
||||
if bestScore < 100 || bestAmbiguous {
|
||||
continue
|
||||
}
|
||||
confidence := "medium"
|
||||
status := "domain_matched"
|
||||
if bestScore >= 100 {
|
||||
confidence = "high"
|
||||
status = "resolved"
|
||||
} else if bestScore < 80 {
|
||||
confidence = "low"
|
||||
}
|
||||
result[input.NormalizedURL] = monitoringAliasResolution{
|
||||
ArticleID: cloneInt64(best.ArticleID),
|
||||
PublishRecordID: cloneInt64(best.PublishRecordID),
|
||||
NormalizedURLKey: best.NormalizedURL,
|
||||
ResolutionStatus: status,
|
||||
ResolutionConfidence: confidence,
|
||||
ResolutionStatus: "resolved",
|
||||
ResolutionConfidence: "high",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2618,23 +2607,19 @@ func scoreMonitoringAliasCandidate(input monitoringAliasLookupInput, candidate m
|
||||
return 100
|
||||
}
|
||||
|
||||
if !monitoringAliasDomainMatches(input, candidate) {
|
||||
return -1
|
||||
}
|
||||
|
||||
score := 40
|
||||
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
|
||||
for _, inputKey := range input.MatchKeys {
|
||||
inputKey = strings.TrimSpace(inputKey)
|
||||
if inputKey == "" {
|
||||
continue
|
||||
}
|
||||
for _, candidateKey := range candidate.MatchKeys {
|
||||
if inputKey == strings.TrimSpace(candidateKey) && candidateKey != "" {
|
||||
return 100
|
||||
}
|
||||
}
|
||||
}
|
||||
return score
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func monitoringAliasDomainMatches(input monitoringAliasLookupInput, candidate monitoringAliasCandidate) bool {
|
||||
|
||||
@@ -425,23 +425,33 @@ func TestBuildMonitoringCitationFactCanDisableContentResolution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreMonitoringAliasCandidateUsesDomainWithTitleEvidence(t *testing.T) {
|
||||
lastPath := "article-42"
|
||||
func TestScoreMonitoringAliasCandidateUsesCanonicalArticleKey(t *testing.T) {
|
||||
input := monitoringAliasLookupInput{
|
||||
NormalizedURL: "https://m.example.com/article-42",
|
||||
RegistrableDomain: "example.com",
|
||||
LastPathSegment: &lastPath,
|
||||
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
|
||||
NormalizedURL: "https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html",
|
||||
MatchKeys: monitoringCitationCandidateKeys("https://m.163.com/dy/article/KRPRE8DJ0556MCR0.html", ""),
|
||||
}
|
||||
candidate := monitoringAliasCandidate{
|
||||
NormalizedURL: "https://www.example.com/posts/article-42",
|
||||
RegistrableDomain: "example.com",
|
||||
LastPathSegment: "article-42",
|
||||
TitleKey: normalizeMonitoringAliasTitle("北京口腔医院全面测评"),
|
||||
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 < 90 {
|
||||
t.Fatalf("scoreMonitoringAliasCandidate() = %d, want strong domain/title match", score)
|
||||
if score := scoreMonitoringAliasCandidate(input, candidate); score != 100 {
|
||||
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(
|
||||
ctx context.Context,
|
||||
days int,
|
||||
brandID int64,
|
||||
keywordID *int64,
|
||||
businessDate string,
|
||||
aiPlatformID *string,
|
||||
) (*MonitoringCitationSummaryResponse, error) {
|
||||
actor := auth.MustActor(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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2481,98 +2507,10 @@ func (s *MonitoringService) loadCitationRanking(
|
||||
}
|
||||
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
||||
|
||||
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
|
||||
r.ai_platform_id,
|
||||
COUNT(*) AS sample_count
|
||||
FROM question_monitor_runs r
|
||||
WHERE r.tenant_id = $1
|
||||
AND ($2::bigint = 0 OR r.brand_id = $2)
|
||||
AND r.collector_type = $3
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||
GROUP BY r.ai_platform_id
|
||||
),
|
||||
citation_sources AS (
|
||||
SELECT
|
||||
r.ai_platform_id,
|
||||
cf.id,
|
||||
cf.run_id,
|
||||
cf.article_id,
|
||||
(
|
||||
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
|
||||
JOIN monitoring_citation_facts cf
|
||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
||||
WHERE r.tenant_id = $1
|
||||
AND ($2::bigint = 0 OR r.brand_id = $2)
|
||||
AND r.collector_type = $3
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||
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))
|
||||
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load citation ranking")
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type citationRankingAggregate struct {
|
||||
CitedAnswerCount int64
|
||||
@@ -2583,14 +2521,29 @@ func (s *MonitoringService) loadCitationRanking(
|
||||
}
|
||||
|
||||
aggregates := make(map[string]citationRankingAggregate)
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
SELECT
|
||||
r.ai_platform_id,
|
||||
COUNT(*) AS sample_count
|
||||
FROM question_monitor_runs r
|
||||
WHERE r.tenant_id = $1
|
||||
AND ($2::bigint = 0 OR r.brand_id = $2)
|
||||
AND r.collector_type = $3
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||
GROUP BY r.ai_platform_id
|
||||
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
||||
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
|
||||
var citedAnswerCount int64
|
||||
var citedArticleCount int64
|
||||
var citationSourceCount int64
|
||||
var saasSourceCount int64
|
||||
if scanErr := rows.Scan(&platformID, &sampleCount, &citedAnswerCount, &citedArticleCount, &citationSourceCount, &saasSourceCount, new(sql.NullFloat64), new(sql.NullFloat64)); scanErr != nil {
|
||||
if scanErr := rows.Scan(&platformID, &sampleCount); scanErr != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
|
||||
}
|
||||
platformID = normalizeMonitoringPlatformID(platformID)
|
||||
@@ -2598,16 +2551,80 @@ func (s *MonitoringService) loadCitationRanking(
|
||||
continue
|
||||
}
|
||||
item := aggregates[platformID]
|
||||
item.CitedAnswerCount += citedAnswerCount
|
||||
item.CitedArticleCount += citedArticleCount
|
||||
item.CitationSourceCount += citationSourceCount
|
||||
item.SaaSSourceCount += saasSourceCount
|
||||
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
|
||||
r.ai_platform_id,
|
||||
cf.run_id,
|
||||
cf.cited_url,
|
||||
cf.normalized_url
|
||||
FROM question_monitor_runs r
|
||||
JOIN monitoring_citation_facts cf
|
||||
ON cf.tenant_id = r.tenant_id AND cf.run_id = r.id
|
||||
WHERE r.tenant_id = $1
|
||||
AND ($2::bigint = 0 OR r.brand_id = $2)
|
||||
AND r.collector_type = $3
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
||||
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 runID int64
|
||||
var citedURL string
|
||||
var normalizedURL string
|
||||
if scanErr := rows.Scan(&platformID, &runID, &citedURL, &normalizedURL); scanErr != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse citation ranking")
|
||||
}
|
||||
platformID = normalizeMonitoringPlatformID(platformID)
|
||||
if platformID == "" {
|
||||
continue
|
||||
}
|
||||
item := aggregates[platformID]
|
||||
item.CitationSourceCount++
|
||||
if platformID != "qwen" {
|
||||
if alias, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
|
||||
item.SaaSSourceCount++
|
||||
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
|
||||
}
|
||||
|
||||
items := make([]MonitoringCitationRanking, 0, len(aggregates))
|
||||
for platformID, aggregate := range aggregates {
|
||||
if aggregate.SaaSSourceCount == 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, MonitoringCitationRanking{
|
||||
AIPlatformID: platformID,
|
||||
PlatformName: platformDisplayName(platformID),
|
||||
@@ -2648,6 +2665,11 @@ func (s *MonitoringService) loadCitedArticles(
|
||||
}
|
||||
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
|
||||
|
||||
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var totalSampleCount int64
|
||||
if err := s.monitoringPool.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
@@ -2664,78 +2686,78 @@ func (s *MonitoringService) loadCitedArticles(
|
||||
}
|
||||
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
WITH article_counts AS (
|
||||
SELECT
|
||||
cf.article_id,
|
||||
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
|
||||
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
|
||||
COUNT(*) AS citation_count
|
||||
FROM monitoring_citation_facts cf
|
||||
JOIN question_monitor_runs r
|
||||
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
|
||||
AND ($2::bigint = 0 OR cf.brand_id = $2)
|
||||
AND cf.article_id IS NOT NULL
|
||||
AND r.ai_platform_id <> 'qwen'
|
||||
AND r.collector_type = $3
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||
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
|
||||
cf.cited_url,
|
||||
cf.normalized_url
|
||||
FROM monitoring_citation_facts cf
|
||||
JOIN question_monitor_runs r
|
||||
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
|
||||
WHERE cf.tenant_id = $1
|
||||
AND ($2::bigint = 0 OR cf.brand_id = $2)
|
||||
AND r.ai_platform_id <> 'qwen'
|
||||
AND r.collector_type = $3
|
||||
AND r.status = 'succeeded'
|
||||
AND r.business_date BETWEEN $4::date AND $5::date
|
||||
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
|
||||
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
|
||||
`, tenantID, brandID, monitoringCollectorType, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs))
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50041, "query_failed", "failed to load cited articles")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]MonitoringCitedArticle, 0)
|
||||
type citedArticleAggregate struct {
|
||||
ArticleID int64
|
||||
ArticleTitle string
|
||||
PublishPlatform string
|
||||
CitationCount int64
|
||||
}
|
||||
aggregates := make(map[int64]citedArticleAggregate)
|
||||
var totalArticleCitationCount int64
|
||||
for rows.Next() {
|
||||
var articleID int64
|
||||
var title string
|
||||
var publishPlatform string
|
||||
var citationCount int64
|
||||
var totalArticleCitationCount int64
|
||||
if scanErr := rows.Scan(&articleID, &title, &publishPlatform, &citationCount, &totalArticleCitationCount); scanErr != nil {
|
||||
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")
|
||||
}
|
||||
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{
|
||||
ArticleID: articleID,
|
||||
ArticleTitle: title,
|
||||
PublishPlatform: publishPlatform,
|
||||
CitationCount: citationCount,
|
||||
CitationRate: divideAsPointer(citationCount, totalSampleCount),
|
||||
SourceShare: divideAsPointer(citationCount, totalArticleCitationCount),
|
||||
ArticleID: item.ArticleID,
|
||||
ArticleTitle: item.ArticleTitle,
|
||||
PublishPlatform: item.PublishPlatform,
|
||||
CitationCount: item.CitationCount,
|
||||
CitationRate: divideAsPointer(item.CitationCount, totalSampleCount),
|
||||
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
|
||||
}
|
||||
@@ -2923,6 +2945,11 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
||||
return result, nil
|
||||
}
|
||||
|
||||
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
WITH filtered_citations AS (
|
||||
SELECT
|
||||
@@ -2930,10 +2957,10 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
||||
cf.run_id,
|
||||
cf.cited_url,
|
||||
cf.cited_title,
|
||||
cf.normalized_url,
|
||||
cf.host,
|
||||
cf.registrable_domain,
|
||||
cf.site_key,
|
||||
cf.article_id,
|
||||
cf.resolution_status,
|
||||
cf.resolution_confidence
|
||||
FROM monitoring_citation_facts cf
|
||||
@@ -2979,10 +3006,9 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
||||
fc.run_id,
|
||||
fc.cited_url,
|
||||
fc.cited_title,
|
||||
fc.normalized_url,
|
||||
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,
|
||||
fc.article_id,
|
||||
alias.article_title_snapshot AS article_title,
|
||||
fc.resolution_status,
|
||||
fc.resolution_confidence
|
||||
FROM filtered_citations fc
|
||||
@@ -2990,8 +3016,6 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
||||
ON dm.host = fc.host
|
||||
AND dm.registrable_domain = fc.registrable_domain
|
||||
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
|
||||
`, tenantID, runIDs)
|
||||
if err != nil {
|
||||
@@ -3003,21 +3027,26 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
||||
var runID int64
|
||||
var citedURL string
|
||||
var citedTitle sql.NullString
|
||||
var normalizedURL string
|
||||
var siteName string
|
||||
var siteKey string
|
||||
var articleID sql.NullInt64
|
||||
var articleTitle sql.NullString
|
||||
var resolutionStatus 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")
|
||||
}
|
||||
siteName = repairMonitoringMojibake(siteName)
|
||||
if citedTitle.Valid {
|
||||
citedTitle.String = repairMonitoringMojibake(citedTitle.String)
|
||||
}
|
||||
if articleTitle.Valid {
|
||||
articleTitle.String = repairMonitoringMojibake(articleTitle.String)
|
||||
var articleID *int64
|
||||
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{
|
||||
CitedURL: citedURL,
|
||||
@@ -3025,12 +3054,15 @@ func (s *MonitoringService) loadCitationsForRuns(ctx context.Context, tenantID i
|
||||
SiteName: siteName,
|
||||
SiteKey: siteKey,
|
||||
FaviconURL: faviconURL(siteKey),
|
||||
ArticleID: nullableInt64Value(articleID),
|
||||
ArticleTitle: nullableStringValue(articleTitle),
|
||||
ArticleID: articleID,
|
||||
ArticleTitle: articleTitle,
|
||||
ResolutionStatus: resolutionStatus,
|
||||
ResolutionConfidence: resolutionConfidence,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate citations")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -3040,15 +3072,21 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
||||
return []MonitoringQuestionCitationStats{}, nil
|
||||
}
|
||||
|
||||
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
WITH filtered_facts AS (
|
||||
SELECT
|
||||
r.ai_platform_id,
|
||||
cf.id,
|
||||
cf.cited_url,
|
||||
cf.normalized_url,
|
||||
cf.host,
|
||||
cf.registrable_domain,
|
||||
cf.site_key,
|
||||
cf.article_id
|
||||
cf.site_key
|
||||
FROM question_monitor_runs r
|
||||
JOIN monitoring_citation_facts cf
|
||||
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
|
||||
LIMIT 1
|
||||
) dm ON TRUE
|
||||
),
|
||||
grouped AS (
|
||||
SELECT
|
||||
ff.ai_platform_id,
|
||||
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_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
|
||||
LEFT JOIN domain_mappings dm
|
||||
ON dm.host = ff.host
|
||||
AND dm.registrable_domain = ff.registrable_domain
|
||||
AND dm.site_key = ff.site_key
|
||||
GROUP BY
|
||||
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
|
||||
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_key, ff.site_key) AS site_key,
|
||||
COALESCE(dm.mapped_domain, ff.site_key, ff.registrable_domain) AS site_domain
|
||||
FROM filtered_facts ff
|
||||
LEFT JOIN domain_mappings dm
|
||||
ON dm.host = ff.host
|
||||
AND dm.registrable_domain = ff.registrable_domain
|
||||
AND dm.site_key = ff.site_key
|
||||
ORDER BY ff.ai_platform_id ASC, ff.id ASC
|
||||
`, tenantID, runIDs)
|
||||
if err != nil {
|
||||
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)
|
||||
for rows.Next() {
|
||||
var platformID string
|
||||
var citedURL string
|
||||
var normalizedURL string
|
||||
var siteName string
|
||||
var siteKey string
|
||||
var siteDomain string
|
||||
var citationCount int64
|
||||
var contentCitationCount int64
|
||||
if scanErr := rows.Scan(&platformID, &siteName, &siteKey, &siteDomain, &citationCount, new(sql.NullFloat64), &contentCitationCount); scanErr != nil {
|
||||
if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL, &siteName, &siteKey, &siteDomain); scanErr != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse question citation analysis")
|
||||
}
|
||||
platformID = normalizeMonitoringPlatformID(platformID)
|
||||
@@ -3174,10 +3183,17 @@ func (s *MonitoringService) loadQuestionCitationAnalysis(ctx context.Context, te
|
||||
SiteDomain: siteDomain,
|
||||
}
|
||||
}
|
||||
item.CitationCount += citationCount
|
||||
item.ContentCitationCount += contentCitationCount
|
||||
item.CitationCount++
|
||||
if platformID != "qwen" {
|
||||
if _, ok := aliasIndex.Match(citedURL, normalizedURL); ok {
|
||||
item.ContentCitationCount++
|
||||
}
|
||||
}
|
||||
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))
|
||||
@@ -3206,40 +3222,23 @@ func (s *MonitoringService) loadQuestionContentCitations(ctx context.Context, te
|
||||
return []MonitoringQuestionContentCitation{}, nil
|
||||
}
|
||||
|
||||
aliasIndex, err := loadMonitoringPublishedAliasIndex(ctx, s.monitoringPool, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := s.monitoringPool.Query(ctx, `
|
||||
WITH platform_totals AS (
|
||||
SELECT
|
||||
r.ai_platform_id,
|
||||
COUNT(cf.id) 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
|
||||
WHERE r.tenant_id = $1
|
||||
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
|
||||
cf.cited_url,
|
||||
cf.normalized_url
|
||||
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.id = ANY($2)
|
||||
GROUP BY cf.article_id, r.ai_platform_id
|
||||
ORDER BY citation_count DESC, cf.article_id ASC
|
||||
`, tenantID, runIDs)
|
||||
if err != nil {
|
||||
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)
|
||||
totals := make(map[string]int64)
|
||||
for rows.Next() {
|
||||
var articleID int64
|
||||
var articleTitle string
|
||||
var publishPlatform string
|
||||
var platformID string
|
||||
var citationCount int64
|
||||
var totalCitationCount int64
|
||||
if scanErr := rows.Scan(&articleID, &articleTitle, &publishPlatform, &platformID, &citationCount, &totalCitationCount); scanErr != nil {
|
||||
var citedURL string
|
||||
var normalizedURL string
|
||||
if scanErr := rows.Scan(&platformID, &citedURL, &normalizedURL); scanErr != nil {
|
||||
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse content citations")
|
||||
}
|
||||
platformID = normalizeMonitoringPlatformID(platformID)
|
||||
if platformID == "" {
|
||||
continue
|
||||
}
|
||||
totals[platformID]++
|
||||
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := contentCitationKey{
|
||||
PlatformID: platformID,
|
||||
ArticleID: articleID,
|
||||
ArticleID: alias.ArticleID,
|
||||
}
|
||||
item := aggregates[key]
|
||||
if item.ArticleID == 0 {
|
||||
item = MonitoringQuestionContentCitation{
|
||||
ArticleID: articleID,
|
||||
ArticleTitle: articleTitle,
|
||||
PublishPlatform: publishPlatform,
|
||||
ArticleID: alias.ArticleID,
|
||||
ArticleTitle: alias.ArticleTitle,
|
||||
PublishPlatform: alias.PublishPlatform,
|
||||
AIPlatformID: platformID,
|
||||
PlatformName: platformDisplayName(platformID),
|
||||
}
|
||||
}
|
||||
item.CitationCount += citationCount
|
||||
item.CitationCount++
|
||||
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))
|
||||
|
||||
@@ -343,7 +343,7 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
|
||||
coverImageAssetID.Set = true
|
||||
coverImageAssetID.Value = &rawCoverImageAssetID
|
||||
}
|
||||
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, nil, coverAssetURL, coverImageAssetID)
|
||||
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, coverAssetURL, coverImageAssetID)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func normalizePlatformIDs(platformIDs []string) []string {
|
||||
@@ -31,52 +34,79 @@ func normalizePlatformIDs(platformIDs []string) []string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
func parsePlatformIDs(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return normalizePlatformIDs(strings.Split(value, ","))
|
||||
type scheduleAutoPublishPlatformQuerier interface {
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
}
|
||||
|
||||
func serializePlatformIDs(platformIDs []string) string {
|
||||
normalized := normalizePlatformIDs(platformIDs)
|
||||
if len(normalized) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(normalized, ",")
|
||||
}
|
||||
|
||||
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
|
||||
func loadScheduleAutoPublishPlatforms(
|
||||
ctx context.Context,
|
||||
db scheduleAutoPublishPlatformQuerier,
|
||||
tenantID int64,
|
||||
inputParamsJSON []byte,
|
||||
) ([]string, error) {
|
||||
if db == nil || tenantID <= 0 || len(inputParamsJSON) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil
|
||||
if err := json.Unmarshal(inputParamsJSON, &payload); err != nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
if !extractBool(payload, "schedule_auto_publish") {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
if platforms := normalizePlatformsFromValue(payload["platforms"]); len(platforms) > 0 {
|
||||
return platforms
|
||||
}
|
||||
if platforms := normalizePlatformsFromValue(payload["target_platforms"]); len(platforms) > 0 {
|
||||
return platforms
|
||||
accountIDs := normalizeSchedulePublishAccountIDs(extractStringList(payload["schedule_publish_account_ids"], 64))
|
||||
if len(accountIDs) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
if targetPlatform, ok := payload["target_platform"].(string); ok {
|
||||
return parsePlatformIDs(targetPlatform)
|
||||
return loadPublishAccountPlatformsByIDs(ctx, db, tenantID, accountIDs)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return 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 {
|
||||
@@ -146,51 +176,21 @@ func extractInt64FromJSONPayload(raw []byte, key string) (int64, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePlatformsFromValue(raw interface{}) []string {
|
||||
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) {
|
||||
func mergeArticleWizardState(raw []byte, title string, coverAssetURL *string, coverImageAssetID NullableInt64Input) ([]byte, error) {
|
||||
state := make(map[string]interface{})
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &state)
|
||||
}
|
||||
|
||||
delete(state, "platforms")
|
||||
delete(state, "target_platforms")
|
||||
delete(state, "target_platform")
|
||||
|
||||
trimmedTitle := strings.TrimSpace(title)
|
||||
if 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 {
|
||||
trimmedCoverURL := strings.TrimSpace(*coverAssetURL)
|
||||
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) {
|
||||
if got := normalizePublishStatus("partial_success"); got != "partial_success" {
|
||||
t.Fatalf("normalizePublishStatus(partial_success) = %q, want partial_success", got)
|
||||
|
||||
@@ -54,26 +54,27 @@ type ScheduleTaskRequest struct {
|
||||
}
|
||||
|
||||
type ScheduleTaskResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
WorkspaceID *int64 `json:"workspace_id"`
|
||||
PromptRuleID int64 `json:"prompt_rule_id"`
|
||||
PromptRuleName *string `json:"prompt_rule_name"`
|
||||
BrandID *int64 `json:"brand_id"`
|
||||
BrandName *string `json:"brand_name"`
|
||||
Name string `json:"name"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
AutoPublish bool `json:"auto_publish"`
|
||||
PublishAccountIDs []string `json:"publish_account_ids"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
||||
EnableWebSearch bool `json:"enable_web_search"`
|
||||
GenerateCount int `json:"generate_count"`
|
||||
StartAt *string `json:"start_at"`
|
||||
EndAt *string `json:"end_at"`
|
||||
NextRunAt *string `json:"next_run_at"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
WorkspaceID *int64 `json:"workspace_id"`
|
||||
PromptRuleID int64 `json:"prompt_rule_id"`
|
||||
PromptRuleName *string `json:"prompt_rule_name"`
|
||||
BrandID *int64 `json:"brand_id"`
|
||||
BrandName *string `json:"brand_name"`
|
||||
Name string `json:"name"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
AutoPublish bool `json:"auto_publish"`
|
||||
PublishAccountIDs []string `json:"publish_account_ids"`
|
||||
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
|
||||
EnableWebSearch bool `json:"enable_web_search"`
|
||||
GenerateCount int `json:"generate_count"`
|
||||
StartAt *string `json:"start_at"`
|
||||
EndAt *string `json:"end_at"`
|
||||
NextRunAt *string `json:"next_run_at"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ScheduleTaskListParams struct {
|
||||
@@ -206,7 +207,7 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
|
||||
return &ScheduleTaskResponse{
|
||||
ID: id, WorkspaceID: &workspaceID, PromptRuleID: req.PromptRuleID, BrandID: req.BrandID,
|
||||
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,
|
||||
GenerateCount: generateCount,
|
||||
Status: status, CreatedAt: fmt.Sprintf("%v", createdAt),
|
||||
@@ -400,6 +401,9 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID in
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.populateScheduleAutoPublishPlatforms(ctx, tenantID, &item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return &ScheduleTaskListResponse{Items: items, Total: total}, nil
|
||||
@@ -425,9 +429,24 @@ func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenant
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
if err := s.populateScheduleAutoPublishPlatforms(ctx, tenantID, &item); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
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 {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (ScheduleTaskResponse, error) {
|
||||
@@ -480,6 +499,7 @@ func (s *ScheduleTaskService) ensurePromptRuleExists(ctx context.Context, tenant
|
||||
type scheduleAutoPublishConfig struct {
|
||||
AutoPublish bool
|
||||
AccountIDs []string
|
||||
PlatformIDs []string
|
||||
AccountIDsJSON []byte
|
||||
CoverAssetURL *string
|
||||
CoverImageAssetID *int64
|
||||
@@ -514,6 +534,7 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
config.PlatformIDs = normalizePlatformIDs(platformIDs)
|
||||
if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" {
|
||||
return config, response.ErrBadRequest(40025, "cover_required", "selected publish accounts require cover_asset_url")
|
||||
}
|
||||
|
||||
@@ -21,39 +21,38 @@ type promptsConfig struct {
|
||||
}
|
||||
|
||||
type runtimePromptsConfig struct {
|
||||
DefaultGenerationBasePromptTemplate string `yaml:"default_generation_base_prompt_template"`
|
||||
PromptContextHeading string `yaml:"prompt_context_heading"`
|
||||
GenerationWritingRequirementsSection string `yaml:"generation_writing_requirements_section"`
|
||||
GenerationTemplateSpecificRulesHeading string `yaml:"generation_template_specific_rules_heading"`
|
||||
GenerationLengthGuidanceHeading string `yaml:"generation_length_guidance_heading"`
|
||||
TopXBrandPriorityRulesTemplate string `yaml:"top_x_brand_priority_rules_template"`
|
||||
EnglishLengthGuidanceTemplate string `yaml:"english_length_guidance_template"`
|
||||
ChineseLengthGuidanceTemplate string `yaml:"chinese_length_guidance_template"`
|
||||
ContextLabels map[string]string `yaml:"context_labels"`
|
||||
TemplateContextWithJSONExampleTemplate string `yaml:"template_context_with_json_example_template"`
|
||||
JSONOutputExampleHeading string `yaml:"json_output_example_heading"`
|
||||
AnalyzeOutputExample string `yaml:"analyze_output_example"`
|
||||
AnalyzeFallbackPromptTemplate string `yaml:"analyze_fallback_prompt_template"`
|
||||
TitleCustomOutputRequirementsSection string `yaml:"title_custom_output_requirements_section"`
|
||||
TitleOutputExample string `yaml:"title_output_example"`
|
||||
TitleFallbackPromptTemplate string `yaml:"title_fallback_prompt_template"`
|
||||
OutlineOutputExample string `yaml:"outline_output_example"`
|
||||
OutlineCustomOutputRequirementsSection string `yaml:"outline_custom_output_requirements_section"`
|
||||
OutlineFallbackPromptTemplate string `yaml:"outline_fallback_prompt_template"`
|
||||
OutlineResponseFormatDescription string `yaml:"outline_response_format_description"`
|
||||
PromptRuleTaskNameSupplementFormat string `yaml:"prompt_rule_task_name_supplement_format"`
|
||||
PromptRuleSceneSupplementFormat string `yaml:"prompt_rule_scene_supplement_format"`
|
||||
PromptRuleToneSupplementFormat string `yaml:"prompt_rule_tone_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"`
|
||||
PromptRuleOutputRequirementsSection string `yaml:"prompt_rule_output_requirements_section"`
|
||||
ArticleImitationPromptTemplate string `yaml:"article_imitation_prompt_template"`
|
||||
ArticleImitationLocaleInstructions map[string]string `yaml:"article_imitation_locale_instructions"`
|
||||
ArticleImitationSettingLabels map[string]string `yaml:"article_imitation_setting_labels"`
|
||||
KnowledgePromptIntroLines []string `yaml:"knowledge_prompt_intro_lines"`
|
||||
KnowledgeSnippetLabelFormat string `yaml:"knowledge_snippet_label_format"`
|
||||
KnowledgeWebsiteMarkdown knowledgeWebsiteMarkdownConfig `yaml:"knowledge_website_markdown"`
|
||||
DefaultGenerationBasePromptTemplate string `yaml:"default_generation_base_prompt_template"`
|
||||
PromptContextHeading string `yaml:"prompt_context_heading"`
|
||||
GenerationWritingRequirementsSection string `yaml:"generation_writing_requirements_section"`
|
||||
GenerationTemplateSpecificRulesHeading string `yaml:"generation_template_specific_rules_heading"`
|
||||
GenerationLengthGuidanceHeading string `yaml:"generation_length_guidance_heading"`
|
||||
TopXBrandPriorityRulesTemplate string `yaml:"top_x_brand_priority_rules_template"`
|
||||
EnglishLengthGuidanceTemplate string `yaml:"english_length_guidance_template"`
|
||||
ChineseLengthGuidanceTemplate string `yaml:"chinese_length_guidance_template"`
|
||||
ContextLabels map[string]string `yaml:"context_labels"`
|
||||
TemplateContextWithJSONExampleTemplate string `yaml:"template_context_with_json_example_template"`
|
||||
JSONOutputExampleHeading string `yaml:"json_output_example_heading"`
|
||||
AnalyzeOutputExample string `yaml:"analyze_output_example"`
|
||||
AnalyzeFallbackPromptTemplate string `yaml:"analyze_fallback_prompt_template"`
|
||||
TitleCustomOutputRequirementsSection string `yaml:"title_custom_output_requirements_section"`
|
||||
TitleOutputExample string `yaml:"title_output_example"`
|
||||
TitleFallbackPromptTemplate string `yaml:"title_fallback_prompt_template"`
|
||||
OutlineOutputExample string `yaml:"outline_output_example"`
|
||||
OutlineCustomOutputRequirementsSection string `yaml:"outline_custom_output_requirements_section"`
|
||||
OutlineFallbackPromptTemplate string `yaml:"outline_fallback_prompt_template"`
|
||||
OutlineResponseFormatDescription string `yaml:"outline_response_format_description"`
|
||||
PromptRuleTaskNameSupplementFormat string `yaml:"prompt_rule_task_name_supplement_format"`
|
||||
PromptRuleSceneSupplementFormat string `yaml:"prompt_rule_scene_supplement_format"`
|
||||
PromptRuleToneSupplementFormat string `yaml:"prompt_rule_tone_supplement_format"`
|
||||
PromptRuleWordCountSupplementFormat string `yaml:"prompt_rule_word_count_supplement_format"`
|
||||
PromptRuleSupplementHeading string `yaml:"prompt_rule_supplement_heading"`
|
||||
PromptRuleOutputRequirementsSection string `yaml:"prompt_rule_output_requirements_section"`
|
||||
ArticleImitationPromptTemplate string `yaml:"article_imitation_prompt_template"`
|
||||
ArticleImitationLocaleInstructions map[string]string `yaml:"article_imitation_locale_instructions"`
|
||||
ArticleImitationSettingLabels map[string]string `yaml:"article_imitation_setting_labels"`
|
||||
KnowledgePromptIntroLines []string `yaml:"knowledge_prompt_intro_lines"`
|
||||
KnowledgeSnippetLabelFormat string `yaml:"knowledge_snippet_label_format"`
|
||||
KnowledgeWebsiteMarkdown knowledgeWebsiteMarkdownConfig `yaml:"knowledge_website_markdown"`
|
||||
}
|
||||
|
||||
type knowledgeWebsiteMarkdownConfig struct {
|
||||
|
||||
@@ -121,13 +121,6 @@ func PromptRuleWordCountSupplement(wordCount int) string {
|
||||
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 {
|
||||
return trimPrompt(loadRuntimePrompts().PromptRuleSupplementHeading) + "\n- " + strings.Join(items, "\n- ")
|
||||
}
|
||||
|
||||
@@ -66,7 +66,21 @@ func (h *MonitoringHandler) CitationSummary(c *gin.Context) {
|
||||
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 {
|
||||
response.Error(c, svcErr)
|
||||
return
|
||||
|
||||
@@ -249,7 +249,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
|
||||
if swagger.EnabledForMode(app.Config.Server.Mode) {
|
||||
swagger.Register(app.Engine, swagger.Config{
|
||||
Title: "Geo Rankly Admin API",
|
||||
Title: "省心推 Admin API",
|
||||
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