Просмотр исходного кода

feat(chatbi): add focused SmartOps demo assistant

Add a DeepSeek-backed ChatBI demo for the SmartOps home and S1 dashboard, with deterministic KPI focus and structured answers. Bump Web to 2.4.117 and server to 1.0.84.

Co-authored-by: Cursor <cursoragent@cursor.com>
skygu 3 недель назад
Родитель
Сommit
dfcd129a37

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.116",
+	"version": "2.4.117",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 52 - 0
Web/src/views/aidop/api/chatBI.ts

@@ -0,0 +1,52 @@
+import service from '/@/utils/request';
+
+export interface ChatBIAskInput {
+	question: string;
+	entry?: string;
+	moduleCode?: string;
+	factoryId?: number;
+	filters?: Record<string, string>;
+}
+
+export interface ChatBIMetricCard {
+	moduleCode: string;
+	metricCode: string;
+	metricName: string;
+	metricLevel: number;
+	currentValue: number | null;
+	targetValue: number | null;
+	unit: string;
+	statusColor: string;
+	gapLabel: string;
+	department: string;
+	trendFlag: string;
+}
+
+export interface ChatBIAction {
+	label: string;
+	url: string;
+}
+
+export interface ChatBIAnswerSection {
+	title: string;
+	content: string;
+	tone: string;
+}
+
+export interface ChatBIAnswerOutput {
+	source: 'deepseek' | 'local' | string;
+	intent: string;
+	focusMetricCode: string;
+	summary: string;
+	answerText: string;
+	contextTitle: string;
+	sections: ChatBIAnswerSection[];
+	metrics: ChatBIMetricCard[];
+	suggestions: string[];
+	actions: ChatBIAction[];
+	isFallback: boolean;
+}
+
+export function askChatBI(body: ChatBIAskInput): Promise<ChatBIAnswerOutput> {
+	return service.post('/api/AidopChatBI/ask', body).then((res) => res.data);
+}

+ 85 - 0
Web/src/views/aidop/chatbi/ChatBIFloat.vue

@@ -0,0 +1,85 @@
+<template>
+	<Teleport to="body">
+		<button type="button" class="chatbi-float" :class="{ 'chatbi-float--s1': moduleCode === 'S1' }" @click="visible = true">
+			<span class="chatbi-float__dot" />
+			<span>{{ buttonText }}</span>
+		</button>
+		<ChatBIPanel
+			v-model:visible="visible"
+			:title="title"
+			:entry="entry"
+			:module-code="moduleCode"
+			:factory-id="factoryId"
+			:filters="filters"
+			:suggestions="suggestions"
+		/>
+	</Teleport>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import ChatBIPanel from './ChatBIPanel.vue';
+
+withDefaults(
+	defineProps<{
+		buttonText?: string;
+		title?: string;
+		entry?: string;
+		moduleCode?: string;
+		factoryId?: number;
+		filters?: Record<string, string>;
+		suggestions?: string[];
+	}>(),
+	{
+		buttonText: 'ChatBI',
+		title: '运营智能问答',
+		entry: 'smart-ops',
+		moduleCode: '',
+		factoryId: 1,
+		filters: () => ({}),
+		suggestions: () => [],
+	}
+);
+
+const visible = ref(false);
+</script>
+
+<style scoped lang="scss">
+.chatbi-float {
+	position: fixed;
+	right: 28px;
+	bottom: 28px;
+	z-index: 2300;
+	display: inline-flex;
+	gap: 8px;
+	align-items: center;
+	height: 44px;
+	padding: 0 18px;
+	font-size: 14px;
+	font-weight: 700;
+	color: #fff;
+	cursor: pointer;
+	background: linear-gradient(135deg, #2563eb, #06b6d4);
+	border: 0;
+	border-radius: 999px;
+	box-shadow: 0 18px 48px rgb(37 99 235 / 32%);
+	transition: transform 0.18s ease, box-shadow 0.18s ease;
+
+	&:hover {
+		transform: translateY(-2px);
+		box-shadow: 0 22px 58px rgb(37 99 235 / 42%);
+	}
+}
+
+.chatbi-float--s1 {
+	bottom: 86px;
+}
+
+.chatbi-float__dot {
+	width: 9px;
+	height: 9px;
+	background: #86efac;
+	border-radius: 50%;
+	box-shadow: 0 0 0 5px rgb(134 239 172 / 18%);
+}
+</style>

+ 238 - 0
Web/src/views/aidop/chatbi/ChatBIMessage.vue

@@ -0,0 +1,238 @@
+<template>
+	<div class="chatbi-message" :class="`chatbi-message--${role}`">
+		<div class="chatbi-message__bubble">
+			<div v-if="role === 'assistant' && output" class="chatbi-message__meta">
+				<span>{{ output.contextTitle || 'ChatBI' }} · {{ intentText(output.intent) }}</span>
+				<el-tag size="small" :type="output.isFallback ? 'warning' : 'success'">
+					{{ output.isFallback ? '本地摘要' : 'DeepSeek' }}
+				</el-tag>
+			</div>
+			<div class="chatbi-message__text">{{ text }}</div>
+
+			<div v-if="output?.sections?.length" class="chatbi-sections">
+				<div v-for="section in output.sections" :key="section.title" class="chatbi-section" :class="`chatbi-section--${section.tone || 'info'}`">
+					<div class="chatbi-section__title">{{ section.title }}</div>
+					<div class="chatbi-section__content">{{ section.content }}</div>
+				</div>
+			</div>
+
+			<div v-if="output?.metrics?.length" class="chatbi-metrics">
+				<div v-for="metric in output.metrics.slice(0, 4)" :key="metric.metricCode" class="chatbi-metric-card">
+					<div class="chatbi-metric-card__head">
+						<span>{{ metric.metricName }}</span>
+						<el-tag size="small" :type="statusTagType(metric.statusColor)">{{ statusText(metric.statusColor) }}</el-tag>
+					</div>
+					<div class="chatbi-metric-card__value">
+						{{ formatMetricValue(metric.currentValue, metric.unit) }}
+						<span>目标 {{ formatMetricValue(metric.targetValue, metric.unit) }}</span>
+					</div>
+					<div class="chatbi-metric-card__foot">
+						<span>{{ metric.moduleCode }} · L{{ metric.metricLevel }}</span>
+						<span v-if="metric.gapLabel">期量差 {{ metric.gapLabel }}</span>
+					</div>
+				</div>
+			</div>
+
+			<div v-if="output?.actions?.length" class="chatbi-actions">
+				<el-button v-for="action in output.actions" :key="action.url" size="small" plain type="primary" @click="go(action.url)">
+					{{ action.label }}
+				</el-button>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { useRouter } from 'vue-router';
+import type { ChatBIAnswerOutput } from '../api/chatBI';
+
+defineProps<{
+	role: 'user' | 'assistant';
+	text: string;
+	output?: ChatBIAnswerOutput | null;
+}>();
+
+const router = useRouter();
+
+function go(url: string) {
+	if (!url) return;
+	router.push(url);
+}
+
+function statusTagType(status?: string) {
+	const s = (status || '').toLowerCase();
+	if (s === 'red') return 'danger';
+	if (s === 'yellow') return 'warning';
+	if (s === 'green') return 'success';
+	return 'info';
+}
+
+function statusText(status?: string) {
+	const s = (status || '').toLowerCase();
+	if (s === 'red') return '红灯';
+	if (s === 'yellow') return '黄灯';
+	if (s === 'green') return '绿灯';
+	return '未知';
+}
+
+function intentText(intent?: string) {
+	const map: Record<string, string> = {
+		global_bottleneck: '全局瓶颈',
+		metric_status: '指标状态',
+		root_cause: '原因分析',
+		improvement_plan: '改善建议',
+		trend_summary: '趋势分析',
+	};
+	return map[intent || ''] || '智能分析';
+}
+
+function formatMetricValue(value: number | null | undefined, unit?: string) {
+	if (value === null || value === undefined) return '-';
+	return `${Number(value).toFixed(2).replace(/\.?0+$/, '')}${unit || ''}`;
+}
+</script>
+
+<style scoped lang="scss">
+.chatbi-message {
+	display: flex;
+	margin-bottom: 12px;
+
+	&--user {
+		justify-content: flex-end;
+
+		.chatbi-message__bubble {
+			max-width: 86%;
+			color: #fff;
+			background: linear-gradient(135deg, #2563eb, #0ea5e9);
+		}
+	}
+
+	&--assistant {
+		justify-content: flex-start;
+
+		.chatbi-message__bubble {
+			width: 100%;
+			color: #1f2937;
+			background: #f8fafc;
+			border: 1px solid #e5e7eb;
+		}
+	}
+}
+
+.chatbi-message__bubble {
+	padding: 12px 14px;
+	border-radius: 14px;
+	box-shadow: 0 8px 24px rgb(15 23 42 / 8%);
+}
+
+.chatbi-message__meta {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	margin-bottom: 8px;
+	font-size: 12px;
+	color: #64748b;
+}
+
+.chatbi-message__text {
+	white-space: pre-wrap;
+	line-height: 1.7;
+}
+
+.chatbi-sections {
+	display: grid;
+	gap: 8px;
+	margin-top: 12px;
+}
+
+.chatbi-section {
+	padding: 10px 12px;
+	background: #fff;
+	border: 1px solid #e5e7eb;
+	border-left: 4px solid #94a3b8;
+	border-radius: 10px;
+
+	&--danger {
+		border-left-color: #ef4444;
+	}
+
+	&--warning {
+		border-left-color: #f59e0b;
+	}
+
+	&--success {
+		border-left-color: #22c55e;
+	}
+
+	&--info {
+		border-left-color: #3b82f6;
+	}
+}
+
+.chatbi-section__title {
+	margin-bottom: 4px;
+	font-size: 12px;
+	font-weight: 700;
+	color: #334155;
+}
+
+.chatbi-section__content {
+	font-size: 13px;
+	line-height: 1.6;
+	color: #1f2937;
+}
+
+.chatbi-metrics {
+	display: grid;
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+	gap: 8px;
+	margin-top: 12px;
+}
+
+.chatbi-metric-card {
+	padding: 10px;
+	background: #fff;
+	border: 1px solid #e5e7eb;
+	border-radius: 10px;
+}
+
+.chatbi-metric-card__head,
+.chatbi-metric-card__foot {
+	display: flex;
+	gap: 8px;
+	align-items: center;
+	justify-content: space-between;
+	font-size: 12px;
+	color: #64748b;
+}
+
+.chatbi-metric-card__head span:first-child {
+	overflow: hidden;
+	font-weight: 600;
+	color: #111827;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.chatbi-metric-card__value {
+	margin: 8px 0;
+	font-size: 20px;
+	font-weight: 700;
+	color: #0f172a;
+
+	span {
+		display: block;
+		margin-top: 2px;
+		font-size: 12px;
+		font-weight: 400;
+		color: #64748b;
+	}
+}
+
+.chatbi-actions {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 8px;
+	margin-top: 12px;
+}
+</style>

+ 214 - 0
Web/src/views/aidop/chatbi/ChatBIPanel.vue

@@ -0,0 +1,214 @@
+<template>
+	<el-drawer v-model="visible" direction="rtl" :size="420" append-to-body class="chatbi-drawer">
+		<template #header>
+			<div class="chatbi-panel__header">
+				<div>
+					<div class="chatbi-panel__eyebrow">Ai-DOP ChatBI</div>
+					<div class="chatbi-panel__title">{{ title }}</div>
+				</div>
+				<el-tag size="small" type="success">Demo</el-tag>
+			</div>
+		</template>
+
+		<div class="chatbi-panel">
+			<div class="chatbi-panel__tips">
+				基于当前页面 KPI 聚合数据回答。DeepSeek 不接触原始明细,也不生成 SQL。
+			</div>
+
+			<div class="chatbi-panel__suggestions">
+				<el-button v-for="item in currentSuggestions" :key="item" size="small" round @click="send(item)">
+					{{ item }}
+				</el-button>
+			</div>
+
+			<el-scrollbar ref="scrollbarRef" class="chatbi-panel__messages">
+				<ChatBIMessage v-for="msg in messages" :key="msg.id" :role="msg.role" :text="msg.text" :output="msg.output" />
+				<div v-if="loading" class="chatbi-panel__loading">
+					<el-icon class="is-loading"><Loading /></el-icon>
+					正在分析当前指标数据...
+				</div>
+			</el-scrollbar>
+
+			<div class="chatbi-panel__input">
+				<el-input
+					v-model="question"
+					type="textarea"
+					:autosize="{ minRows: 2, maxRows: 4 }"
+					placeholder="例如:当前全局最严重的问题是什么?"
+					@keydown.ctrl.enter.prevent="send()"
+				/>
+				<el-button type="primary" :loading="loading" @click="send()">发送</el-button>
+			</div>
+		</div>
+	</el-drawer>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, ref } from 'vue';
+import { ElMessage } from 'element-plus';
+import { Loading } from '@element-plus/icons-vue';
+import { askChatBI, type ChatBIAnswerOutput } from '../api/chatBI';
+import ChatBIMessage from './ChatBIMessage.vue';
+
+const props = withDefaults(
+	defineProps<{
+		title?: string;
+		entry?: string;
+		moduleCode?: string;
+		factoryId?: number;
+		filters?: Record<string, string>;
+		suggestions?: string[];
+	}>(),
+	{
+		title: '运营智能问答',
+		entry: 'smart-ops',
+		moduleCode: '',
+		factoryId: 1,
+		filters: () => ({}),
+		suggestions: () => [],
+	}
+);
+
+const visible = defineModel<boolean>('visible', { default: false });
+
+type ChatMessage = {
+	id: number;
+	role: 'user' | 'assistant';
+	text: string;
+	output?: ChatBIAnswerOutput | null;
+};
+
+const question = ref('');
+const loading = ref(false);
+const scrollbarRef = ref();
+const messages = ref<ChatMessage[]>([
+	{
+		id: Date.now(),
+		role: 'assistant',
+		text: '你好,我可以基于当前九宫格或 S1 看板的 KPI 聚合数据,帮你快速判断红黄指标、瓶颈和下一步改善建议。',
+		output: null,
+	},
+]);
+
+const currentSuggestions = computed(() => {
+	if (props.suggestions.length) return props.suggestions;
+	return props.moduleCode
+		? [`${props.moduleCode} 当前最严重的指标是什么?`, '订单评审周期为什么红了?', '下一步应该创建什么改善计划?']
+		: ['当前全局最严重的问题是什么?', '哪些模块出现红灯指标?', 'S1 产销协同有什么风险?'];
+});
+
+async function send(preset?: string) {
+	const q = (preset ?? question.value).trim();
+	if (!q || loading.value) return;
+
+	messages.value.push({ id: Date.now() + Math.random(), role: 'user', text: q });
+	question.value = '';
+	loading.value = true;
+	scrollToBottom();
+
+	try {
+		const answer = await askChatBI({
+			question: q,
+			entry: props.entry,
+			moduleCode: props.moduleCode || undefined,
+			factoryId: props.factoryId,
+			filters: props.filters,
+		});
+		messages.value.push({
+			id: Date.now() + Math.random(),
+			role: 'assistant',
+			text: answer.answerText || answer.summary || '已完成分析,但暂无文字摘要。',
+			output: answer,
+		});
+	} catch (error: any) {
+		ElMessage.error(error?.message || 'ChatBI 请求失败');
+		messages.value.push({
+			id: Date.now() + Math.random(),
+			role: 'assistant',
+			text: '当前 ChatBI 请求失败,请检查后端服务或 DeepSeek 配置。若只是 Demo,可稍后重试。',
+			output: null,
+		});
+	} finally {
+		loading.value = false;
+		scrollToBottom();
+	}
+}
+
+function scrollToBottom() {
+	nextTick(() => {
+		const wrap = scrollbarRef.value?.wrapRef as HTMLElement | undefined;
+		if (wrap) wrap.scrollTop = wrap.scrollHeight;
+	});
+}
+</script>
+
+<style scoped lang="scss">
+.chatbi-panel {
+	display: flex;
+	flex-direction: column;
+	height: calc(100vh - 96px);
+}
+
+.chatbi-panel__header {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	width: 100%;
+}
+
+.chatbi-panel__eyebrow {
+	font-size: 12px;
+	color: #64748b;
+	letter-spacing: 0.08em;
+	text-transform: uppercase;
+}
+
+.chatbi-panel__title {
+	margin-top: 2px;
+	font-size: 16px;
+	font-weight: 700;
+	color: #0f172a;
+}
+
+.chatbi-panel__tips {
+	padding: 10px 12px;
+	margin-bottom: 10px;
+	font-size: 12px;
+	line-height: 1.6;
+	color: #475569;
+	background: #eff6ff;
+	border: 1px solid #bfdbfe;
+	border-radius: 12px;
+}
+
+.chatbi-panel__suggestions {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 8px;
+	margin-bottom: 12px;
+}
+
+.chatbi-panel__messages {
+	flex: 1;
+	min-height: 0;
+	padding-right: 4px;
+}
+
+.chatbi-panel__loading {
+	display: flex;
+	gap: 8px;
+	align-items: center;
+	padding: 12px;
+	font-size: 13px;
+	color: #64748b;
+}
+
+.chatbi-panel__input {
+	display: grid;
+	grid-template-columns: 1fr auto;
+	gap: 10px;
+	padding-top: 12px;
+	margin-top: 12px;
+	border-top: 1px solid #e5e7eb;
+}
+</style>

+ 13 - 0
Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue

@@ -252,6 +252,16 @@
 				</div>
 			</section>
 		</div>
+		<ChatBIFloat
+			v-if="moduleCode === 'S1'"
+			title="S1 产销协同智能问答"
+			entry="smart-ops-s1"
+			button-text="问一下"
+			module-code="S1"
+			:factory-id="1"
+			:filters="chatBIFilters"
+			:suggestions="chatBISuggestions"
+		/>
 	</div>
 </template>
 
@@ -262,6 +272,7 @@ import { ElMessage } from 'element-plus';
 import * as echarts from 'echarts';
 import DetailQueryBar from './DetailQueryBar.vue';
 import SmartOpsBaseQueryFields from './SmartOpsBaseQueryFields.vue';
+import ChatBIFloat from '../../chatbi/ChatBIFloat.vue';
 import {
 	fetchDynamicDashboardPage,
 	type DynamicDashboardMetric,
@@ -297,6 +308,8 @@ const activeTab = ref('');
 const activeL2MetricCode = ref('');
 const activeL3MetricCode = ref('');
 const pageTabs = computed(() => page.value?.tabs || []);
+const chatBIFilters = computed(() => buildExtraParams());
+const chatBISuggestions = ['S1 当前最严重的指标是什么?', '订单评审周期为什么红了?', '下一步应该创建什么改善计划?'];
 const useTabs = computed(() => page.value?.navigationMode === 'tabs_by_l1' && pageTabs.value.length > 0);
 const useCardTabs = computed(() => page.value?.navigationMode === 'l1_cards' && pageTabs.value.length > 0);
 const coreWidgets = computed(() =>

+ 15 - 0
Web/src/views/dashboard/home.vue

@@ -233,6 +233,14 @@
         @click="navigateTo('s7')"
       />
     </div>
+    <ChatBIFloat
+      title="九宫格运营智能问答"
+      entry="smart-ops-home"
+      button-text="ChatBI"
+      :factory-id="1"
+      :filters="chatBIFilters"
+      :suggestions="chatBISuggestions"
+    />
   </div>
 </template>
 
@@ -263,6 +271,7 @@ import {
   summarizeSmartOpsBaseQuery,
 } from '../aidop/kanban/utils/smartOpsBaseQuery'
 import HomeModuleKpiCard from './components/HomeModuleKpiCard.vue'
+import ChatBIFloat from '../aidop/chatbi/ChatBIFloat.vue'
 
 const router = useRouter()
 const gridMainRef = ref(null)
@@ -275,6 +284,12 @@ const dashboardVersion = `V${__NEXT_VERSION__}`
 
 /** 供 HomeModuleKpiCard 的 extra prop 透传基础查询参数 */
 const kpiExtraParams = computed(() => baseQueryToApiParams(dashboardBaseQuery.value))
+const chatBIFilters = computed(() => kpiExtraParams.value)
+const chatBISuggestions = [
+  '当前全局最严重的问题是什么?',
+  '哪些模块出现红灯指标?',
+  'S1 产销协同有什么风险?',
+]
 
 /** 顶栏时间(与参考图格式一致:YYYY-MM-DD HH:mm:ss) */
 const headerDateTime = ref('')

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.83</AssemblyVersion>
-    <FileVersion>1.0.83</FileVersion>
-    <Version>1.0.83</Version>
+    <AssemblyVersion>1.0.84</AssemblyVersion>
+    <FileVersion>1.0.84</FileVersion>
+    <Version>1.0.84</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 28 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ChatBI/ChatBIController.cs

@@ -0,0 +1,28 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+
+namespace Admin.NET.Plugin.AiDOP.ChatBI;
+
+[ApiController]
+[Route("api/AidopChatBI")]
+[AllowAnonymous]
+[NonUnify]
+public sealed class ChatBIController : ControllerBase
+{
+    private readonly ChatBIService _service;
+
+    public ChatBIController(ChatBIService service)
+    {
+        _service = service;
+    }
+
+    [HttpPost("ask")]
+    public async Task<IActionResult> Ask([FromBody] ChatBIAskInput input, CancellationToken cancellationToken)
+    {
+        if (input == null || string.IsNullOrWhiteSpace(input.Question))
+            return BadRequest(new { message = "问题不能为空" });
+
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var answer = await _service.AskAsync(input, tenantId, cancellationToken);
+        return Ok(answer);
+    }
+}

+ 55 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ChatBI/ChatBIDtos.cs

@@ -0,0 +1,55 @@
+namespace Admin.NET.Plugin.AiDOP.ChatBI;
+
+public sealed class ChatBIAskInput
+{
+    public string? Question { get; set; }
+    public string? Entry { get; set; }
+    public string? ModuleCode { get; set; }
+    public long FactoryId { get; set; } = 1;
+    public Dictionary<string, string>? Filters { get; set; }
+}
+
+public sealed class ChatBIAnswerOutput
+{
+    public string Source { get; set; } = "local";
+    public string Intent { get; set; } = string.Empty;
+    public string FocusMetricCode { get; set; } = string.Empty;
+    public string Summary { get; set; } = string.Empty;
+    public string AnswerText { get; set; } = string.Empty;
+    public string ContextTitle { get; set; } = string.Empty;
+    public List<ChatBIAnswerSection> Sections { get; set; } = new();
+    public List<ChatBIMetricCard> Metrics { get; set; } = new();
+    public List<string> Suggestions { get; set; } = new();
+    public List<ChatBIAction> Actions { get; set; } = new();
+    public bool IsFallback { get; set; }
+}
+
+public sealed class ChatBIAnswerSection
+{
+    public string Title { get; set; } = string.Empty;
+    public string Content { get; set; } = string.Empty;
+    public string Tone { get; set; } = string.Empty;
+}
+
+public sealed class ChatBIMetricCard
+{
+    public long Id { get; set; }
+    public long? ParentId { get; set; }
+    public string ModuleCode { get; set; } = string.Empty;
+    public string MetricCode { get; set; } = string.Empty;
+    public string MetricName { get; set; } = string.Empty;
+    public int MetricLevel { get; set; }
+    public decimal? CurrentValue { get; set; }
+    public decimal? TargetValue { get; set; }
+    public string Unit { get; set; } = string.Empty;
+    public string StatusColor { get; set; } = string.Empty;
+    public string GapLabel { get; set; } = string.Empty;
+    public string Department { get; set; } = string.Empty;
+    public string TrendFlag { get; set; } = string.Empty;
+}
+
+public sealed class ChatBIAction
+{
+    public string Label { get; set; } = string.Empty;
+    public string Url { get; set; } = string.Empty;
+}

+ 510 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ChatBI/ChatBIService.cs

@@ -0,0 +1,510 @@
+using System.Text;
+using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+
+namespace Admin.NET.Plugin.AiDOP.ChatBI;
+
+public sealed class ChatBIService : ITransient
+{
+    private static readonly string[] ValueTables =
+    {
+        "ado_s9_kpi_value_l1_day",
+        "ado_s9_kpi_value_l2_day",
+        "ado_s9_kpi_value_l3_day",
+        "ado_s9_kpi_value_l4_day"
+    };
+
+    private readonly ISqlSugarClient _db;
+    private readonly DeepSeekChatClient _deepSeek;
+
+    public ChatBIService(ISqlSugarClient db, DeepSeekChatClient deepSeek)
+    {
+        _db = db;
+        _deepSeek = deepSeek;
+    }
+
+    public async Task<ChatBIAnswerOutput> AskAsync(ChatBIAskInput input, long tenantId, CancellationToken cancellationToken = default)
+    {
+        var question = string.IsNullOrWhiteSpace(input.Question) ? "当前最需要关注的问题是什么?" : input.Question.Trim();
+        var moduleCode = NormalizeModuleCode(input.ModuleCode);
+        var intent = ClassifyIntent(question, moduleCode);
+        var allMetrics = await LoadMetricCardsAsync(tenantId, input.FactoryId <= 0 ? 1 : input.FactoryId, moduleCode);
+        var focus = ResolveFocusMetric(allMetrics, question, intent);
+        var hasExplicitMetric = HasExplicitMetricMention(allMetrics, question);
+        var metrics = BuildContextMetrics(allMetrics, focus, intent, moduleCode, hasExplicitMetric);
+        var contextTitle = moduleCode == null ? "九宫格全局运营看板" : $"{moduleCode} {ResolveModuleName(moduleCode)}";
+        var fallback = BuildDeterministicAnswer(question, contextTitle, intent, focus, metrics);
+
+        var llmAnswer = await TryBuildDeepSeekAnswerAsync(question, contextTitle, input.Filters, fallback, metrics, cancellationToken);
+        if (!string.IsNullOrWhiteSpace(llmAnswer))
+        {
+            fallback.Source = "deepseek";
+            fallback.IsFallback = false;
+            fallback.AnswerText = llmAnswer;
+            fallback.Summary = FirstSentence(llmAnswer);
+        }
+
+        fallback.ContextTitle = contextTitle;
+        fallback.Actions = BuildActions(moduleCode, focus ?? metrics.FirstOrDefault());
+        fallback.Suggestions = BuildSuggestions(moduleCode, intent, focus ?? metrics.FirstOrDefault());
+        return fallback;
+    }
+
+    private async Task<List<ChatBIMetricCard>> LoadMetricCardsAsync(long tenantId, long factoryId, string? moduleCode)
+    {
+        var kpiQuery = _db.Queryable<AdoSmartOpsKpiMaster>()
+            .Where(x => x.TenantId == tenantId && x.IsEnabled);
+        if (!string.IsNullOrWhiteSpace(moduleCode))
+            kpiQuery = kpiQuery.Where(x => x.ModuleCode == moduleCode);
+
+        var kpis = await kpiQuery
+            .OrderBy(x => x.MetricLevel)
+            .OrderBy(x => x.SortNo)
+            .ToListAsync();
+        var values = await LoadCurrentValuesAsync(tenantId, factoryId, moduleCode);
+
+        var cards = kpis
+            .Select(kpi =>
+            {
+                values.TryGetValue(kpi.MetricCode, out var value);
+                var current = value?.MetricValue;
+                var target = value?.TargetValue;
+                var status = AidopS4KpiMerge.AchievementLevel(
+                    current,
+                    target,
+                    kpi.Direction ?? "higher_is_better",
+                    kpi.YellowThreshold,
+                    kpi.RedThreshold);
+                var gap = AidopS4KpiMerge.GapValue(current, target);
+
+                return new ChatBIMetricCard
+                {
+                    Id = kpi.Id,
+                    ParentId = kpi.ParentId,
+                    ModuleCode = kpi.ModuleCode,
+                    MetricCode = kpi.MetricCode,
+                    MetricName = kpi.MetricName,
+                    MetricLevel = kpi.MetricLevel,
+                    CurrentValue = current,
+                    TargetValue = target,
+                    Unit = kpi.Unit ?? "",
+                    StatusColor = status,
+                    GapLabel = FormatGapLabel(gap, kpi.Unit),
+                    Department = kpi.Department ?? "",
+                    TrendFlag = value?.TrendFlag ?? ""
+                };
+            })
+            .Where(x => x.CurrentValue != null || x.TargetValue != null)
+            .ToList();
+        return cards;
+    }
+
+    private async Task<Dictionary<string, MetricValueRow>> LoadCurrentValuesAsync(long tenantId, long factoryId, string? moduleCode)
+    {
+        var result = new Dictionary<string, MetricValueRow>(StringComparer.OrdinalIgnoreCase);
+        for (var i = 0; i < ValueTables.Length; i++)
+        {
+            var table = ValueTables[i];
+            try
+            {
+                var bizDate = await ResolveMaxBizDateAsync(table, tenantId, factoryId, moduleCode);
+                if (bizDate == null) continue;
+
+                var sql = string.IsNullOrWhiteSpace(moduleCode)
+                    ? $"""
+SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue, status_color AS StatusColor, trend_flag AS TrendFlag
+FROM {table}
+WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0 AND biz_date=@bizDate
+"""
+                    : $"""
+SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue, status_color AS StatusColor, trend_flag AS TrendFlag
+FROM {table}
+WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0 AND biz_date=@bizDate
+""";
+
+                var rows = await _db.Ado.SqlQueryAsync<MetricValueRow>(sql, new { tenantId, factoryId, moduleCode, bizDate });
+                foreach (var row in rows.Where(x => !string.IsNullOrWhiteSpace(x.MetricCode)))
+                {
+                    row.Level = i + 1;
+                    result[row.MetricCode!] = row;
+                }
+            }
+            catch
+            {
+                // Demo 读取允许某一层指标值表暂缺,避免 ChatBI 整体不可用。
+            }
+        }
+
+        return result;
+    }
+
+    private async Task<DateTime?> ResolveMaxBizDateAsync(string table, long tenantId, long factoryId, string? moduleCode)
+    {
+        var sql = string.IsNullOrWhiteSpace(moduleCode)
+            ? $"SELECT MAX(biz_date) FROM {table} WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0"
+            : $"SELECT MAX(biz_date) FROM {table} WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0";
+        return await _db.Ado.GetDateTimeAsync(sql, new { tenantId, factoryId, moduleCode });
+    }
+
+    private async Task<string?> TryBuildDeepSeekAnswerAsync(
+        string question,
+        string contextTitle,
+        Dictionary<string, string>? filters,
+        ChatBIAnswerOutput deterministic,
+        List<ChatBIMetricCard> metrics,
+        CancellationToken cancellationToken)
+    {
+        if (metrics.Count == 0) return null;
+
+        var systemPrompt = """
+你是 Ai-DOP 制造运营 ChatBI 助手。后端已经完成意图识别、指标选择和事实校验。
+你的任务只是把“确定性回答结构”润色成清晰中文,不得新增未给出的原因、单据、负责人或数值。
+如果用户问题已经指定某个指标,只能围绕焦点指标和 KPI 聚合摘要中列出的真实下级指标回答,不得引用同模块其它兄弟指标。
+请按「结论 / 证据 / 可能原因 / 下一步」四段输出,每段 1 到 2 句,总字数控制在 220 字以内。
+""";
+        var userPrompt = new StringBuilder()
+            .AppendLine($"入口:{contextTitle}")
+            .AppendLine($"用户问题:{question}")
+            .AppendLine($"识别意图:{deterministic.Intent}")
+            .AppendLine($"焦点指标:{deterministic.FocusMetricCode}")
+            .AppendLine($"筛选条件:{FormatFilters(filters)}")
+            .AppendLine("确定性回答结构:");
+        foreach (var section in deterministic.Sections)
+            userPrompt.AppendLine($"- {section.Title}:{section.Content}");
+        userPrompt
+            .AppendLine("KPI 聚合摘要:");
+        foreach (var m in metrics.Take(10))
+        {
+            userPrompt.AppendLine(
+                $"- {m.ModuleCode} {m.MetricName}({m.MetricCode}, L{m.MetricLevel}):当前 {FormatValue(m.CurrentValue, m.Unit)},目标 {FormatValue(m.TargetValue, m.Unit)},状态 {StatusText(m.StatusColor)},期量差 {m.GapLabel},责任 {FallbackText(m.Department, "未配置")}");
+        }
+
+        return await _deepSeek.CompleteAsync(systemPrompt, userPrompt.ToString(), cancellationToken);
+    }
+
+    private static ChatBIAnswerOutput BuildDeterministicAnswer(
+        string question,
+        string contextTitle,
+        string intent,
+        ChatBIMetricCard? focus,
+        List<ChatBIMetricCard> metrics)
+    {
+        var sections = BuildSections(question, contextTitle, intent, focus, metrics);
+        var answer = sections.Count == 0
+            ? $"已收到问题“{question}”。当前 {contextTitle} 暂未读取到可用于分析的 KPI 聚合值,请先确认指标日值表是否有数据。"
+            : string.Join("\n", sections.Select(x => $"{x.Title}:{x.Content}"));
+
+        return new ChatBIAnswerOutput
+        {
+            Source = "local",
+            IsFallback = true,
+            Intent = intent,
+            FocusMetricCode = focus?.MetricCode ?? "",
+            Summary = focus == null ? "暂未读取到 KPI 聚合数据" : $"重点关注:{focus.MetricName}",
+            AnswerText = answer,
+            Sections = sections,
+            Metrics = metrics
+        };
+    }
+
+    private static List<ChatBIAction> BuildActions(string? moduleCode, ChatBIMetricCard? focus)
+    {
+        var primaryModule = moduleCode ?? focus?.ModuleCode ?? "S1";
+        var metricCode = focus?.MetricLevel == 1 ? focus.MetricCode : null;
+        return new List<ChatBIAction>
+        {
+            new() { Label = $"查看 {primaryModule} 看板", Url = $"/aidop/smart-ops/{primaryModule.ToLowerInvariant()}" },
+            new()
+            {
+                Label = "打开智慧诊断",
+                Url = string.IsNullOrWhiteSpace(metricCode)
+                    ? $"/aidop/smart-diagnosis?module={primaryModule}"
+                    : $"/aidop/smart-diagnosis?module={primaryModule}&metricCode={metricCode}"
+            }
+        };
+    }
+
+    private static List<string> BuildSuggestions(string? moduleCode, string intent, ChatBIMetricCard? focus)
+    {
+        if (moduleCode == null)
+        {
+            return new List<string>
+            {
+                "当前全局最严重的问题是什么?",
+                "哪些模块出现红灯指标?",
+                focus == null ? "S1 产销协同有什么风险?" : $"{focus.ModuleCode} 的 {focus.MetricName} 为什么异常?"
+            };
+        }
+
+        return new List<string>
+        {
+            $"{moduleCode} 当前最严重的指标是什么?",
+            focus == null ? "订单评审周期为什么红了?" : $"{focus.MetricName} 为什么异常?",
+            intent == "improvement_plan" ? "如何验证改善是否有效?" : "下一步应该创建什么改善计划?"
+        };
+    }
+
+    private static string ClassifyIntent(string question, string? moduleCode)
+    {
+        if (ContainsAny(question, "改善", "计划", "措施", "下一步", "怎么处理", "怎么办")) return "improvement_plan";
+        if (ContainsAny(question, "为什么", "原因", "异常", "红", "黄", "没达标", "未达标")) return "root_cause";
+        if (ContainsAny(question, "趋势", "变化", "近", "最近", "连续")) return "trend_summary";
+        return moduleCode == null ? "global_bottleneck" : "metric_status";
+    }
+
+    private static ChatBIMetricCard? ResolveFocusMetric(List<ChatBIMetricCard> metrics, string question, string intent)
+    {
+        var exact = metrics
+            .Select(x => new { Metric = x, Score = MetricMatchScore(question, x) })
+            .Where(x => x.Score > 0)
+            .OrderByDescending(x => x.Score)
+            .ThenByDescending(x => StatusRank(x.Metric.StatusColor))
+            .ThenBy(x => x.Metric.MetricLevel)
+            .FirstOrDefault();
+        if (exact != null) return exact.Metric;
+
+        var preferredLevels = intent == "global_bottleneck" ? new[] { 1, 2, 3, 4 } : new[] { 1, 2, 3, 4 };
+        return metrics
+            .OrderByDescending(x => StatusRank(x.StatusColor))
+            .ThenBy(x => Array.IndexOf(preferredLevels, x.MetricLevel))
+            .ThenBy(x => x.MetricCode)
+            .FirstOrDefault();
+    }
+
+    private static List<ChatBIMetricCard> BuildContextMetrics(
+        List<ChatBIMetricCard> allMetrics,
+        ChatBIMetricCard? focus,
+        string intent,
+        string? moduleCode,
+        bool hasExplicitMetric)
+    {
+        if (focus == null)
+            return allMetrics.OrderByDescending(x => StatusRank(x.StatusColor)).ThenBy(x => x.MetricLevel).Take(moduleCode == null ? 9 : 10).ToList();
+
+        var sameModule = allMetrics.Where(x => string.Equals(x.ModuleCode, focus.ModuleCode, StringComparison.OrdinalIgnoreCase)).ToList();
+        var context = new List<ChatBIMetricCard> { focus };
+
+        if (hasExplicitMetric)
+        {
+            context.AddRange(sameModule
+                .Where(x => x.MetricCode != focus.MetricCode && IsDescendantOf(x, focus, sameModule))
+                .OrderByDescending(x => StatusRank(x.StatusColor))
+                .ThenBy(x => x.MetricLevel)
+                .ThenBy(x => x.MetricCode)
+                .Take(9));
+
+            return context
+                .GroupBy(x => x.MetricCode, StringComparer.OrdinalIgnoreCase)
+                .Select(x => x.First())
+                .Take(10)
+                .ToList();
+        }
+
+        if (intent is "root_cause" or "improvement_plan")
+        {
+            context.AddRange(sameModule
+                .Where(x => x.MetricCode != focus.MetricCode && x.MetricLevel >= focus.MetricLevel)
+                .OrderByDescending(x => StatusRank(x.StatusColor))
+                .ThenBy(x => x.MetricLevel)
+                .Take(7));
+        }
+        else if (moduleCode == null)
+        {
+            context.AddRange(allMetrics
+                .Where(x => x.MetricCode != focus.MetricCode && x.MetricLevel == 1)
+                .OrderByDescending(x => StatusRank(x.StatusColor))
+                .ThenBy(x => x.ModuleCode)
+                .Take(8));
+        }
+        else
+        {
+            context.AddRange(sameModule
+                .Where(x => x.MetricCode != focus.MetricCode)
+                .OrderByDescending(x => StatusRank(x.StatusColor))
+                .ThenBy(x => x.MetricLevel)
+                .Take(7));
+        }
+
+        return context
+            .GroupBy(x => x.MetricCode, StringComparer.OrdinalIgnoreCase)
+            .Select(x => x.First())
+            .Take(10)
+            .ToList();
+    }
+
+    private static List<ChatBIAnswerSection> BuildSections(
+        string question,
+        string contextTitle,
+        string intent,
+        ChatBIMetricCard? focus,
+        List<ChatBIMetricCard> metrics)
+    {
+        if (focus == null) return new List<ChatBIAnswerSection>();
+
+        var relatedRisks = metrics
+            .Where(x => x.MetricCode != focus.MetricCode && StatusRank(x.StatusColor) >= 2)
+            .OrderByDescending(x => StatusRank(x.StatusColor))
+            .ThenBy(x => x.MetricLevel)
+            .Take(3)
+            .ToList();
+        var riskText = relatedRisks.Count == 0
+            ? "当前上下文未发现更多红黄下层指标,需进入智慧诊断继续看明细证据。"
+            : "相关红黄指标包括:" + string.Join("、", relatedRisks.Select(x => $"{x.MetricName}({StatusText(x.StatusColor)})")) + "。";
+        var nextAction = intent switch
+        {
+            "improvement_plan" => $"建议围绕 {focus.MetricName} 建立改善计划,目标值先按 {FormatValue(focus.TargetValue, focus.Unit)} 对齐,并把红黄下层指标作为行动项来源。",
+            "root_cause" => $"建议打开智慧诊断,从 {focus.MetricName} 下钻到 L2/L3/L4,确认责任部门、卡点和单据后再创建改善计划。",
+            "trend_summary" => $"建议结合近 7 到 14 天趋势复核 {focus.MetricName} 是否连续恶化,再决定是否升级为改善任务。",
+            _ => $"建议优先跟进 {focus.MetricName},若持续红黄则进入智慧诊断并创建改善任务。"
+        };
+
+        return new List<ChatBIAnswerSection>
+        {
+            new()
+            {
+                Title = "结论",
+                Tone = StatusRank(focus.StatusColor) >= 3 ? "danger" : StatusRank(focus.StatusColor) == 2 ? "warning" : "info",
+                Content = $"{contextTitle} 当前焦点是 {focus.MetricName},状态为{StatusText(focus.StatusColor)}。"
+            },
+            new()
+            {
+                Title = "证据",
+                Tone = "info",
+                Content = $"{focus.MetricName} 当前 {FormatValue(focus.CurrentValue, focus.Unit)},目标 {FormatValue(focus.TargetValue, focus.Unit)},期量差 {FallbackText(focus.GapLabel, "-")}。"
+            },
+            new()
+            {
+                Title = "可能原因",
+                Tone = relatedRisks.Count > 0 ? "warning" : "info",
+                Content = riskText
+            },
+            new()
+            {
+                Title = "下一步",
+                Tone = "success",
+                Content = nextAction
+            }
+        };
+    }
+
+    private static string? NormalizeModuleCode(string? moduleCode)
+    {
+        var mc = (moduleCode ?? "").Trim().ToUpperInvariant();
+        return mc is "S1" or "S2" or "S3" or "S4" or "S5" or "S6" or "S7" or "S9" ? mc : null;
+    }
+
+    private static bool QuestionMatchesMetric(string question, ChatBIMetricCard metric)
+    {
+        if (string.IsNullOrWhiteSpace(question)) return false;
+        return question.Contains(metric.MetricName, StringComparison.OrdinalIgnoreCase)
+               || question.Contains(metric.MetricCode, StringComparison.OrdinalIgnoreCase);
+    }
+
+    private static bool HasExplicitMetricMention(List<ChatBIMetricCard> metrics, string question)
+    {
+        return metrics.Any(metric => QuestionMatchesMetric(question, metric));
+    }
+
+    private static bool IsDescendantOf(ChatBIMetricCard candidate, ChatBIMetricCard ancestor, List<ChatBIMetricCard> sameModule)
+    {
+        var byId = sameModule.Where(x => x.Id > 0).ToDictionary(x => x.Id);
+        var parentId = candidate.ParentId;
+        while (parentId != null)
+        {
+            if (parentId.Value == ancestor.Id) return true;
+            if (!byId.TryGetValue(parentId.Value, out var parent)) return false;
+            parentId = parent.ParentId;
+        }
+
+        return false;
+    }
+
+    private static int MetricMatchScore(string question, ChatBIMetricCard metric)
+    {
+        var score = 0;
+        if (QuestionMatchesMetric(question, metric)) score += 100;
+        foreach (var token in SplitMetricName(metric.MetricName))
+        {
+            if (token.Length >= 2 && question.Contains(token, StringComparison.OrdinalIgnoreCase)) score += 10;
+        }
+        return score;
+    }
+
+    private static IEnumerable<string> SplitMetricName(string metricName)
+    {
+        var separators = new[] { ' ', '/', '-', '_', '(', ')', '(', ')', ':', ':', '、' };
+        return (metricName ?? "").Split(separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+    }
+
+    private static int StatusRank(string? status) => (status ?? "").ToLowerInvariant() switch
+    {
+        "red" => 3,
+        "yellow" => 2,
+        "green" => 1,
+        _ => 0
+    };
+
+    private static string StatusText(string? status) => (status ?? "").ToLowerInvariant() switch
+    {
+        "red" => "红灯",
+        "yellow" => "黄灯",
+        "green" => "绿灯",
+        _ => "未知"
+    };
+
+    private static string ResolveModuleName(string moduleCode) => moduleCode.ToUpperInvariant() switch
+    {
+        "S1" => "产销协同动态详情看板",
+        "S2" => "制造协同动态详情看板",
+        "S3" => "供应协同动态详情看板",
+        "S4" => "采购执行动态详情看板",
+        "S5" => "物料仓储动态详情看板",
+        "S6" => "生产执行动态详情看板",
+        "S7" => "成品仓储动态详情看板",
+        "S9" => "运营指标动态详情看板",
+        _ => "动态详情看板"
+    };
+
+    private static bool ContainsAny(string text, params string[] needles)
+    {
+        return needles.Any(x => text.Contains(x, StringComparison.OrdinalIgnoreCase));
+    }
+
+    private static string FormatGapLabel(decimal? gap, string? unit)
+    {
+        if (gap == null) return "";
+        var rounded = decimal.Round(gap.Value, 2);
+        return $"{rounded:0.##}{unit ?? ""}";
+    }
+
+    private static string FormatValue(decimal? value, string? unit)
+    {
+        if (value == null) return "-";
+        return $"{decimal.Round(value.Value, 2):0.##}{unit ?? ""}";
+    }
+
+    private static string FormatFilters(Dictionary<string, string>? filters)
+    {
+        if (filters == null || filters.Count == 0) return "未填写条件(展示全部)";
+        return string.Join(";", filters.Where(x => !string.IsNullOrWhiteSpace(x.Value)).Select(x => $"{x.Key}={x.Value}"));
+    }
+
+    private static string FallbackText(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value;
+
+    private static string FirstSentence(string text)
+    {
+        var trimmed = text.Trim();
+        var idx = trimmed.IndexOfAny(new[] { '。', '!', '?', '\n' });
+        return idx > 0 ? trimmed[..Math.Min(idx + 1, trimmed.Length)] : trimmed;
+    }
+
+    private sealed class MetricValueRow
+    {
+        public int Level { get; set; }
+        public string? MetricCode { get; set; }
+        public decimal? MetricValue { get; set; }
+        public decimal? TargetValue { get; set; }
+        public string? StatusColor { get; set; }
+        public string? TrendFlag { get; set; }
+    }
+}

+ 51 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ChatBI/DeepSeekChatClient.cs

@@ -0,0 +1,51 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.ChatBI;
+
+public sealed class DeepSeekChatClient : ITransient
+{
+    private readonly IHttpClientFactory _httpClientFactory;
+
+    public DeepSeekChatClient(IHttpClientFactory httpClientFactory)
+    {
+        _httpClientFactory = httpClientFactory;
+    }
+
+    public async Task<string?> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken cancellationToken = default)
+    {
+        var options = App.GetConfig<DeepSeekOptions>("DeepSeekSettings", true);
+        if (options == null || string.IsNullOrWhiteSpace(options.ApiUrl) || string.IsNullOrWhiteSpace(options.ApiKey))
+            return null;
+
+        var client = _httpClientFactory.CreateClient("AidopChatBI.DeepSeek");
+        using var request = new HttpRequestMessage(HttpMethod.Post, options.ApiUrl);
+        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
+
+        var body = new
+        {
+            model = "deepseek-chat",
+            messages = new[]
+            {
+                new { role = "system", content = systemPrompt },
+                new { role = "user", content = userPrompt }
+            },
+            temperature = 0.2,
+            max_tokens = 1200
+        };
+        request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
+
+        using var response = await client.SendAsync(request, cancellationToken);
+        if (!response.IsSuccessStatusCode)
+            return null;
+
+        var json = await response.Content.ReadAsStringAsync(cancellationToken);
+        using var doc = JsonDocument.Parse(json);
+        if (!doc.RootElement.TryGetProperty("choices", out var choices) || choices.GetArrayLength() == 0)
+            return null;
+
+        var message = choices[0].GetProperty("message");
+        return message.TryGetProperty("content", out var content) ? content.GetString()?.Trim() : null;
+    }
+}

+ 4 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -1,4 +1,5 @@
 using System.Diagnostics;
+using Admin.NET.Plugin.AiDOP.ChatBI;
 using Admin.NET.Plugin.AiDOP.Entity;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Quality;
@@ -25,6 +26,9 @@ public class Startup : AppStartup
     public void ConfigureServices(IServiceCollection services)
     {
         services.AddHttpClient();
+        services.AddHttpClient("AidopChatBI.DeepSeek");
+        services.AddScoped<DeepSeekChatClient>();
+        services.AddScoped<ChatBIService>();
         services.AddConfigurableOptions<S8ActiveFlowWatchOptions>();
         services.AddScoped<AdoS0ExceptionFilter>();
         services.AddScoped<AdoS0ResultFilter>();