提交
This commit is contained in:
247
src/api/ydoyun/aiAssistantReport/index.ts
Normal file
247
src/api/ydoyun/aiAssistantReport/index.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
/** 保存报告单据请求 */
|
||||
export interface AiAssistantReportSaveReq {
|
||||
/** 传入则更新;不传则后端按 报告人+模块编码+报告日期 唯一 upsert */
|
||||
id?: number
|
||||
moduleName: string
|
||||
moduleCode?: string
|
||||
reporter: string
|
||||
reporterId: number
|
||||
reportTime: string
|
||||
reportContent?: string
|
||||
screenshotUrl?: string
|
||||
}
|
||||
|
||||
/** 历史报告项 */
|
||||
export interface AiAssistantReportItem {
|
||||
id: number
|
||||
moduleName: string
|
||||
moduleCode?: string
|
||||
reporter: string
|
||||
reporterId: number
|
||||
reportTime: string
|
||||
reportContent?: string
|
||||
screenshotUrl?: string
|
||||
createTime: string
|
||||
}
|
||||
|
||||
/** 每日汇报详表行(与主表一对多) */
|
||||
export interface AiAssistantReportDetailItem {
|
||||
id?: number
|
||||
reportId?: number
|
||||
/** 诊断项目 */
|
||||
diagnosisItem?: string
|
||||
/** 指标异常 */
|
||||
value1?: string
|
||||
/** 归因分析 */
|
||||
value2?: string
|
||||
/** 改善对策 */
|
||||
value3?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
/** 详表批量保存(全量替换) */
|
||||
export interface AiAssistantReportDetailBatchSaveReq {
|
||||
reportId: number
|
||||
details: Array<{
|
||||
diagnosisItem?: string
|
||||
value1?: string
|
||||
value2?: string
|
||||
value3?: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** 分页查询参数(与后端 PageParam 一致:pageSize 为 -1 时不分页、一次查全量) */
|
||||
export interface AiAssistantReportPageParams {
|
||||
reporterId: number
|
||||
moduleCode?: string
|
||||
reportTimeStart?: string
|
||||
reportTimeEnd?: string
|
||||
pageNo?: number
|
||||
/** 每页条数;传 -1 表示不分页(PAGE_SIZE_NONE) */
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
/** 分页结果 */
|
||||
export interface AiAssistantReportPageResult {
|
||||
list: AiAssistantReportItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 按日期聚合报告数量(轨迹图) */
|
||||
export interface ReportCountByDateItem {
|
||||
reportTime: string
|
||||
count: number
|
||||
}
|
||||
|
||||
/** Dify 知识库(数据集)元信息,由后端持久化 datasetId 等 */
|
||||
export interface DifyKbInfoVO {
|
||||
datasetId?: string
|
||||
/** 聚合同步文档 ID,与控制台路径 .../documents/{documentId} 一致 */
|
||||
documentId?: string
|
||||
datasetName?: string
|
||||
knowledgeBaseUrl?: string
|
||||
lastSyncTime?: string
|
||||
created?: boolean
|
||||
}
|
||||
|
||||
/** 单次同步接口返回 */
|
||||
export interface DifyKbSyncResultVO {
|
||||
datasetId?: string
|
||||
documentId?: string
|
||||
datasetName?: string
|
||||
knowledgeBaseUrl?: string
|
||||
syncRecordId?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** 同步历史一行 */
|
||||
export interface DifyKbSyncLogItem {
|
||||
id: number
|
||||
moduleCode?: string
|
||||
moduleName?: string
|
||||
reportId?: number
|
||||
datasetId?: string
|
||||
syncStatus?: string
|
||||
syncMessage?: string
|
||||
creator?: string
|
||||
createTime: string
|
||||
}
|
||||
|
||||
export const AiAssistantReportApi = {
|
||||
/** 保存报告单据 */
|
||||
save: (data: AiAssistantReportSaveReq) =>
|
||||
request.post<number>({
|
||||
url: '/ydoyun/ai-assistant-report/save',
|
||||
data
|
||||
}),
|
||||
|
||||
/** 查询当前人当前模块的历史报告 */
|
||||
getMyList: (params: { reporterId: number; moduleCode?: string }) =>
|
||||
request.get<AiAssistantReportItem[]>({
|
||||
url: '/ydoyun/ai-assistant-report/my-list',
|
||||
params
|
||||
}),
|
||||
|
||||
/** 分页查询历史报告(按最新记录排序) */
|
||||
getMyReportPage: (params: AiAssistantReportPageParams) =>
|
||||
request.get<AiAssistantReportPageResult>({
|
||||
url: '/ydoyun/ai-assistant-report/my-page',
|
||||
params
|
||||
}),
|
||||
|
||||
/** 按部门+统计日一次查询全员汇报(替代 N 次 my-page) */
|
||||
listDeptDailyReports: (params: {
|
||||
deptId: number
|
||||
reportDate: string
|
||||
moduleCode?: string
|
||||
}) =>
|
||||
request.get<AiAssistantReportItem[]>({
|
||||
url: '/ydoyun/ai-assistant-report/dept-daily-list',
|
||||
params
|
||||
}),
|
||||
|
||||
/** 查询有报告的日期列表(用于日历点状图,不提供补写) */
|
||||
getMyReportDates: (params: {
|
||||
reporterId: number
|
||||
moduleCode?: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
}) =>
|
||||
request.get<string[]>({
|
||||
url: '/ydoyun/ai-assistant-report/my-report-dates',
|
||||
params
|
||||
}),
|
||||
|
||||
/** 按日期聚合报告提交数量(用于轨迹图) */
|
||||
getReportCountByDate: (params: {
|
||||
reporterId: number
|
||||
moduleCode?: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
}) =>
|
||||
request.get<ReportCountByDateItem[]>({
|
||||
url: '/ydoyun/ai-assistant-report/report-count-by-date',
|
||||
params
|
||||
}),
|
||||
|
||||
/** 按日期查询报告列表(日历点击查看详情) */
|
||||
getListByDate: (params: {
|
||||
reporterId: number
|
||||
moduleCode?: string
|
||||
reportDate: string
|
||||
}) =>
|
||||
request.get<AiAssistantReportItem[]>({
|
||||
url: '/ydoyun/ai-assistant-report/list-by-date',
|
||||
params
|
||||
}),
|
||||
|
||||
/** 删除本人「报告日期为当天」的一条记录(后端校验) */
|
||||
deleteMyReportToday: (id: number) =>
|
||||
request.delete<boolean>({ url: '/ydoyun/ai-assistant-report/delete?id=' + id }),
|
||||
|
||||
/** 按每日汇报主键查询详表 */
|
||||
getDetailList: (reportId: number) =>
|
||||
request.get<AiAssistantReportDetailItem[]>({
|
||||
url: '/ydoyun/ai-assistant-report/detail/list',
|
||||
params: { reportId }
|
||||
}),
|
||||
|
||||
/** 管理端:按主键查详表(报表知识库/日志填报;需 ydoyun:dify-kb:query,可查他人汇报) */
|
||||
getDetailListForManage: (reportId: number) =>
|
||||
request.get<AiAssistantReportDetailItem[]>({
|
||||
url: '/ydoyun/ai-assistant-report/detail/list-for-manage',
|
||||
params: { reportId }
|
||||
}),
|
||||
|
||||
/** 批量保存详表(与主表分步调用;同步 await 主保存后再调) */
|
||||
saveDetailBatch: (data: AiAssistantReportDetailBatchSaveReq) =>
|
||||
request.post<boolean>({
|
||||
url: '/ydoyun/ai-assistant-report/detail/save-batch',
|
||||
data
|
||||
}),
|
||||
|
||||
/** Dify 智能解析报告正文 → AI经营诊断详表行(不落库) */
|
||||
difyParseDiagnosis: (data: { text: string }) =>
|
||||
request.post<AiAssistantReportDetailItem[]>({
|
||||
url: '/ydoyun/ai-assistant-report/dify-parse-diagnosis',
|
||||
data
|
||||
}),
|
||||
|
||||
/**
|
||||
* 将当前租户、当前页面(moduleCode)下的知识库与 Dify 同步。
|
||||
* 后端约定:每个租户 × 每个页面(模块)对应一个 Dify 知识库/数据集,
|
||||
* 内容包含本租户在该页面下全部店长的历史汇报(由服务端聚合写入)。
|
||||
* 先落库当前汇报再调用;返回知识库 id 与可访问链接。
|
||||
*/
|
||||
syncDifyKnowledge: (data: {
|
||||
reportId: number
|
||||
moduleCode?: string
|
||||
moduleName?: string
|
||||
/** 当前路由对应后台菜单 id,与 meta.menuId 一致;用于 Dify 知识库命名 */
|
||||
menuId?: number
|
||||
}) =>
|
||||
request.post<DifyKbSyncResultVO>({
|
||||
url: '/ydoyun/ai-assistant-report/dify-knowledge/sync',
|
||||
data
|
||||
}),
|
||||
|
||||
/** 当前租户 + 当前模块 已绑定的 Dify 知识库元数据 */
|
||||
getDifyKbInfo: (params: { moduleCode?: string }) =>
|
||||
request.get<DifyKbInfoVO>({
|
||||
url: '/ydoyun/ai-assistant-report/dify-knowledge/info',
|
||||
params
|
||||
}),
|
||||
|
||||
/** 同步记录分页(当前租户、当前模块) */
|
||||
getDifyKbSyncLogs: (params: {
|
||||
moduleCode?: string
|
||||
pageNo?: number
|
||||
pageSize?: number
|
||||
}) =>
|
||||
request.get<{ list: DifyKbSyncLogItem[]; total: number }>({
|
||||
url: '/ydoyun/ai-assistant-report/dify-knowledge/sync-logs',
|
||||
params
|
||||
})
|
||||
}
|
||||
36
src/api/ydoyun/aiModulePrompt/index.ts
Normal file
36
src/api/ydoyun/aiModulePrompt/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface AiModulePromptSaveReq {
|
||||
id?: number
|
||||
componentName: string
|
||||
moduleName: string
|
||||
moduleKey: string
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
export interface AiModulePromptResp {
|
||||
id: number
|
||||
componentName: string
|
||||
moduleName: string
|
||||
moduleKey: string
|
||||
prompt?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export const AiModulePromptApi = {
|
||||
save: (data: AiModulePromptSaveReq) =>
|
||||
request.post({ url: '/ydoyun/ai-module-prompt/save', data }),
|
||||
|
||||
listByComponent: (componentName: string) =>
|
||||
request.get<AiModulePromptResp[]>({
|
||||
url: '/ydoyun/ai-module-prompt/list-by-component',
|
||||
params: { componentName }
|
||||
}),
|
||||
|
||||
getPromptMapByComponent: (componentName: string) =>
|
||||
request.get<Record<string, string>>({
|
||||
url: '/ydoyun/ai-module-prompt/map-by-component',
|
||||
params: { componentName }
|
||||
})
|
||||
}
|
||||
140
src/api/ydoyun/aichat/index.ts
Normal file
140
src/api/ydoyun/aichat/index.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { getAccessToken, getTenantId, getVisitTenantId } from '@/utils/auth'
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL
|
||||
|
||||
export type AiChatStreamDonePayload = { aborted?: boolean }
|
||||
|
||||
/**
|
||||
* AI 对话流式请求(直接上传图片文件)
|
||||
* 使用 FormData: query + file(可选) + task_desc(可选) + conversation_id(可选,多轮对话)
|
||||
*/
|
||||
export async function aiChatStream(
|
||||
query: string,
|
||||
file: File | null | undefined,
|
||||
onChunk: (text: string) => void,
|
||||
onError?: (err: string) => void,
|
||||
onDone?: (payload?: AiChatStreamDonePayload) => void,
|
||||
options?: {
|
||||
taskDesc?: string
|
||||
conversationId?: string
|
||||
onConversationId?: (id: string) => void
|
||||
/** 传入后可调用 AbortController.abort() 中止 fetch 与读流 */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<void> {
|
||||
const token = getAccessToken()
|
||||
const formData = new FormData()
|
||||
formData.append('query', query)
|
||||
if (file) {
|
||||
formData.append('file', file)
|
||||
}
|
||||
if (options?.taskDesc) {
|
||||
formData.append('task_desc', options.taskDesc)
|
||||
}
|
||||
if (options?.conversationId) {
|
||||
formData.append('conversation_id', options.conversationId)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(token ? { Authorization: 'Bearer ' + token } : {})
|
||||
}
|
||||
const tenantId = getTenantId()
|
||||
if (tenantId) {
|
||||
headers['tenant-id'] = String(tenantId)
|
||||
}
|
||||
const visitTenantId = getVisitTenantId()
|
||||
if (visitTenantId) {
|
||||
headers['visit-tenant-id'] = String(visitTenantId)
|
||||
}
|
||||
|
||||
const signal = options?.signal
|
||||
let aborted = false
|
||||
let finished = false
|
||||
|
||||
const finish = () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
onDone?.({ aborted })
|
||||
}
|
||||
|
||||
const processBlock = (block: string) => {
|
||||
if (!block.startsWith('data: ')) return
|
||||
const data = block.slice(6).trim()
|
||||
if (!data || data === '[DONE]') return
|
||||
try {
|
||||
const obj = JSON.parse(data)
|
||||
if (obj.error) {
|
||||
onError?.(obj.error)
|
||||
return
|
||||
}
|
||||
if (obj.conversation_id) {
|
||||
options?.onConversationId?.(obj.conversation_id)
|
||||
}
|
||||
if (obj.text) {
|
||||
onChunk(obj.text)
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(BASE_URL + '/ydoyun/ai-chat/stream', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
signal
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
onError?.(err || '请求失败')
|
||||
return
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
onError?.('无法读取响应流')
|
||||
return
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
aborted = true
|
||||
await reader.cancel().catch(() => {})
|
||||
break
|
||||
}
|
||||
const { done, value } = await reader.read()
|
||||
buffer += value ? decoder.decode(value, { stream: true }) : ''
|
||||
const lines = buffer.split('\n\n')
|
||||
buffer = lines.pop() || ''
|
||||
for (const block of lines) {
|
||||
processBlock(block)
|
||||
}
|
||||
if (done) {
|
||||
if (buffer.trim().startsWith('data: ')) {
|
||||
processBlock(buffer.trim())
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (signal?.aborted) aborted = true
|
||||
finish()
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const name = (e as Error)?.name
|
||||
if (name === 'AbortError' || signal?.aborted) {
|
||||
aborted = true
|
||||
finish()
|
||||
} else {
|
||||
const msg = e instanceof Error ? e.message : '请求异常'
|
||||
onError?.(msg || '请求异常')
|
||||
if (!finished) finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/api/ydoyun/difyKbManage/index.ts
Normal file
65
src/api/ydoyun/difyKbManage/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
/** 当前租户已绑定的 Dify 知识库 */
|
||||
export interface DifyKbManageItem {
|
||||
id: number
|
||||
moduleCode?: string
|
||||
moduleName?: string
|
||||
datasetId?: string
|
||||
datasetName?: string
|
||||
aggregateDocumentId?: string
|
||||
existsOnDify?: boolean
|
||||
}
|
||||
|
||||
export interface DifyKbDocument {
|
||||
id: string
|
||||
name?: string
|
||||
createdAt?: number
|
||||
indexingStatus?: string
|
||||
displayStatus?: string
|
||||
}
|
||||
|
||||
export interface DifyKbModuleOption {
|
||||
moduleCode: string
|
||||
moduleName?: string
|
||||
}
|
||||
|
||||
export interface DifyKbDocumentContent {
|
||||
content?: string
|
||||
truncated?: boolean
|
||||
segmentPieces?: number
|
||||
}
|
||||
|
||||
export const DifyKbManageApi = {
|
||||
/** 列表(仅当前租户;可选 moduleCode 精确、moduleName 模糊) */
|
||||
listBindings: async (params?: { moduleCode?: string; moduleName?: string }) => {
|
||||
return await request.get({ url: '/ydoyun/dify-kb-manage/list', params })
|
||||
},
|
||||
|
||||
/** 模块筛选项(去重) */
|
||||
getModuleOptions: async () => {
|
||||
return await request.get({ url: '/ydoyun/dify-kb-manage/module-options' })
|
||||
},
|
||||
|
||||
getDocumentPage: async (params: { datasetId: string; pageNo: number; pageSize: number }) => {
|
||||
return await request.get({ url: '/ydoyun/dify-kb-manage/document/page', params })
|
||||
},
|
||||
|
||||
getDocumentContent: async (params: { datasetId: string; documentId: string }) => {
|
||||
return await request.get({ url: '/ydoyun/dify-kb-manage/document/content', params })
|
||||
},
|
||||
|
||||
deleteDataset: async (datasetId: string) => {
|
||||
return await request.delete({
|
||||
url: '/ydoyun/dify-kb-manage/dataset/delete',
|
||||
params: { datasetId }
|
||||
})
|
||||
},
|
||||
|
||||
deleteDocument: async (datasetId: string, documentId: string) => {
|
||||
return await request.delete({
|
||||
url: '/ydoyun/dify-kb-manage/document/delete',
|
||||
params: { datasetId, documentId }
|
||||
})
|
||||
}
|
||||
}
|
||||
12
src/api/ydoyun/weather/index.ts
Normal file
12
src/api/ydoyun/weather/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
/**
|
||||
* 后端代理高德天气,返回实况 + 近 3 日预报聚合。
|
||||
* @param location 城市名称(如「长沙市」)或 6 位行政区划编码 adcode
|
||||
*/
|
||||
export const getWeatherAggregate = (location: string) => {
|
||||
return request.get<any>({
|
||||
url: '/ydoyun/weather/wttr',
|
||||
params: { location }
|
||||
})
|
||||
}
|
||||
68
src/components/AiAssistant/AiAssistantMark.vue
Normal file
68
src/components/AiAssistant/AiAssistantMark.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="ai-assistant-mark">
|
||||
<div ref="contentRef" class="ai-assistant-mark-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="showButton"
|
||||
:size="buttonSize"
|
||||
:type="buttonType"
|
||||
class="mark-trigger"
|
||||
@click="onTrigger"
|
||||
>
|
||||
<Icon :icon="buttonIcon" class="mark-icon" />
|
||||
{{ buttonText }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAiAssistant } from './useAiAssistant'
|
||||
|
||||
defineOptions({ name: 'AiAssistantMark' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 是否显示触发按钮 */
|
||||
showButton?: boolean
|
||||
/** 按钮文字 */
|
||||
buttonText?: string
|
||||
/** 按钮图标 */
|
||||
buttonIcon?: string
|
||||
/** 按钮尺寸 */
|
||||
buttonSize?: 'default' | 'small' | 'large'
|
||||
/** 按钮类型 */
|
||||
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'default'
|
||||
/** 模块 AI 提示词,发送时会与用户输入拼接 */
|
||||
prompt?: string
|
||||
}>(),
|
||||
{
|
||||
showButton: true,
|
||||
buttonText: 'AI分析',
|
||||
buttonIcon: 'ep:chat-dot-round',
|
||||
buttonSize: 'small',
|
||||
buttonType: 'primary'
|
||||
}
|
||||
)
|
||||
|
||||
const contentRef = ref<HTMLElement>()
|
||||
const { openWithScreenshot } = useAiAssistant()
|
||||
|
||||
const onTrigger = () => {
|
||||
if (!contentRef.value) return
|
||||
openWithScreenshot(contentRef.value, { prompt: props.prompt })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-assistant-mark {
|
||||
position: relative;
|
||||
}
|
||||
.mark-trigger {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.mark-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
654
src/components/AiAssistant/AiAssistantProvider.vue
Normal file
654
src/components/AiAssistant/AiAssistantProvider.vue
Normal file
@@ -0,0 +1,654 @@
|
||||
<template>
|
||||
<slot></slot>
|
||||
<!-- 全局右下角悬浮按钮(参考 AIRBT.html 样式);登录页不展示 -->
|
||||
<div
|
||||
v-if="showAssistantFab"
|
||||
ref="fabRef"
|
||||
class="ai-assistant-fab"
|
||||
:class="{ 'is-loading': pageLoading, 'is-hidden': dialogVisible, 'is-dragging': fabDragging }"
|
||||
:style="fabPositionStyle"
|
||||
@pointerdown="onFabPointerDown"
|
||||
>
|
||||
<div class="fab-icon-wrap">
|
||||
<Icon v-if="!pageLoading" icon="ep:chat-dot-round" class="fab-icon" />
|
||||
<Icon v-else icon="ep:loading" class="fab-icon fab-loading" />
|
||||
</div>
|
||||
<span class="fab-text">AI 决策助手</span>
|
||||
</div>
|
||||
|
||||
<AiImageChatDialog
|
||||
v-model="dialogVisible"
|
||||
:initial-payload="initialPayload"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import html2canvas from 'html2canvas'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import AiImageChatDialog from './AiImageChatDialog.vue'
|
||||
import {
|
||||
AI_ASSISTANT_KEY,
|
||||
AI_CHAT_MESSAGES_KEY,
|
||||
AI_ASSISTANT_REPORT_CONTEXT_KEY,
|
||||
type AiAssistantReportContext
|
||||
} from './useAiAssistant'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
defineOptions({ name: 'AiAssistantProvider' })
|
||||
|
||||
/**
|
||||
* html2canvas@1.x 无法解析 CSS Color 4/5 的 color()、lab()、oklch() 等,会在解析阶段抛错。
|
||||
* 在克隆 DOM 上用安全色覆盖(仅影响克隆树,不影响真实页面)。
|
||||
* 需处理截图根节点及其子树,以及 html/body 祖先链(CSS 变量常挂在根上)。
|
||||
*/
|
||||
function sanitizeClonedDomForHtml2Canvas(clonedDoc: Document, clonedRoot: HTMLElement) {
|
||||
const win = clonedDoc.defaultView
|
||||
if (!win) return
|
||||
|
||||
const unsafe = (v: string) =>
|
||||
typeof v === 'string' && /\b(color|lab|lch|oklch|oklab|hwb)\(/i.test(v)
|
||||
|
||||
const patchOne = (el: Element) => {
|
||||
try {
|
||||
if (el instanceof HTMLElement) {
|
||||
const cs = win.getComputedStyle(el)
|
||||
const keys = [
|
||||
'color',
|
||||
'background-color',
|
||||
'border-top-color',
|
||||
'border-right-color',
|
||||
'border-bottom-color',
|
||||
'border-left-color',
|
||||
'outline-color',
|
||||
'column-rule-color',
|
||||
'caret-color'
|
||||
] as const
|
||||
for (const key of keys) {
|
||||
const val = cs.getPropertyValue(key)
|
||||
if (val && unsafe(val)) {
|
||||
el.style.setProperty(key, key === 'color' ? '#303133' : '#ebeef5', 'important')
|
||||
}
|
||||
}
|
||||
const bg = cs.getPropertyValue('background')
|
||||
if (bg && unsafe(bg)) {
|
||||
el.style.setProperty('background', 'none', 'important')
|
||||
el.style.setProperty('background-color', '#ffffff', 'important')
|
||||
}
|
||||
const bgImg = cs.getPropertyValue('background-image')
|
||||
if (bgImg && unsafe(bgImg)) {
|
||||
el.style.setProperty('background-image', 'none', 'important')
|
||||
const bgc = cs.getPropertyValue('background-color')
|
||||
if (!bgc || unsafe(bgc)) {
|
||||
el.style.setProperty('background-color', '#fafafa', 'important')
|
||||
}
|
||||
}
|
||||
const bs = cs.getPropertyValue('box-shadow')
|
||||
if (bs && unsafe(bs)) {
|
||||
el.style.setProperty('box-shadow', 'none', 'important')
|
||||
}
|
||||
const ts = cs.getPropertyValue('text-shadow')
|
||||
if (ts && unsafe(ts)) {
|
||||
el.style.setProperty('text-shadow', 'none', 'important')
|
||||
}
|
||||
const flt = cs.getPropertyValue('filter')
|
||||
if (flt && unsafe(flt)) {
|
||||
el.style.setProperty('filter', 'none', 'important')
|
||||
}
|
||||
// 内联 style 里含 color()/lab() 等时尝试替换,仍无法解析则去掉内联避免拖垮 html2canvas
|
||||
const inline = el.getAttribute('style')
|
||||
if (inline && unsafe(inline)) {
|
||||
let s = inline
|
||||
.replace(/\bcolor\([^)]*\)/gi, 'rgb(48,49,51)')
|
||||
.replace(/\b(?:lab|lch|oklch|oklab|hwb)\([^)]*\)/gi, 'rgb(200,200,200)')
|
||||
if (unsafe(s)) {
|
||||
el.removeAttribute('style')
|
||||
} else {
|
||||
el.setAttribute('style', s)
|
||||
}
|
||||
}
|
||||
} else if (el instanceof SVGElement) {
|
||||
const cs = win.getComputedStyle(el)
|
||||
;['fill', 'stroke'].forEach((attr) => {
|
||||
const val = cs.getPropertyValue(attr) || el.getAttribute(attr) || ''
|
||||
if (unsafe(val)) {
|
||||
el.setAttribute(attr, attr === 'fill' ? '#409eff' : '#303133')
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* 单节点忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
let ancestor: HTMLElement | null = clonedRoot
|
||||
while (ancestor) {
|
||||
patchOne(ancestor)
|
||||
ancestor = ancestor.parentElement
|
||||
}
|
||||
|
||||
const walk = (el: Element) => {
|
||||
patchOne(el)
|
||||
for (const child of Array.from(el.children)) {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(clonedRoot)
|
||||
|
||||
// 覆盖 :root 上可能含 color() 的主题变量(html2canvas 会解析到并报错)
|
||||
if (clonedDoc.head && !clonedDoc.getElementById('html2canvas-el-vars-override')) {
|
||||
const inj = clonedDoc.createElement('style')
|
||||
inj.id = 'html2canvas-el-vars-override'
|
||||
inj.textContent = `
|
||||
:root, html, body {
|
||||
--el-color-primary: #409eff !important;
|
||||
--el-color-primary-light-3: #79bbff !important;
|
||||
--el-color-primary-light-5: #a0cfff !important;
|
||||
--el-color-primary-light-7: #c6e2ff !important;
|
||||
--el-color-primary-light-8: #d9ecff !important;
|
||||
--el-color-primary-light-9: #ecf5ff !important;
|
||||
--el-color-primary-dark-2: #337ecc !important;
|
||||
--el-color-success: #67c23a !important;
|
||||
--el-color-warning: #e6a23c !important;
|
||||
--el-color-danger: #f56c6c !important;
|
||||
--el-color-info: #909399 !important;
|
||||
--el-text-color-primary: #303133 !important;
|
||||
--el-text-color-regular: #606266 !important;
|
||||
--el-text-color-secondary: #909399 !important;
|
||||
--el-text-color-placeholder: #a8abb2 !important;
|
||||
--el-text-color-disabled: #c0c4cc !important;
|
||||
--el-border-color: #dcdfe6 !important;
|
||||
--el-border-color-light: #e4e7ed !important;
|
||||
--el-border-color-lighter: #ebeef5 !important;
|
||||
--el-border-color-extra-light: #f2f6fc !important;
|
||||
--el-fill-color: #f0f2f5 !important;
|
||||
--el-fill-color-light: #f5f7fa !important;
|
||||
--el-fill-color-lighter: #fafafa !important;
|
||||
--el-fill-color-blank: #ffffff !important;
|
||||
--el-bg-color: #ffffff !important;
|
||||
--el-bg-color-page: #f2f3f5 !important;
|
||||
}
|
||||
`
|
||||
clonedDoc.head.appendChild(inj)
|
||||
}
|
||||
}
|
||||
|
||||
function runHtml2CanvasOnClone(clonedDoc: Document, clonedNode: HTMLElement, transparentPixel: string) {
|
||||
sanitizeClonedDomForHtml2Canvas(clonedDoc, clonedNode)
|
||||
const imgs = clonedNode.querySelectorAll('img')
|
||||
imgs.forEach((img) => {
|
||||
const src = img.src || img.getAttribute('src')
|
||||
if (!src || src.startsWith('data:') || src.startsWith('blob:')) return
|
||||
try {
|
||||
if (new URL(src, window.location.href).origin !== window.location.origin) {
|
||||
const w = img.width || img.offsetWidth || (img as HTMLImageElement).naturalWidth
|
||||
const h = img.height || img.offsetHeight || (img as HTMLImageElement).naturalHeight
|
||||
img.src = transparentPixel
|
||||
img.removeAttribute('crossorigin')
|
||||
if (w && h) {
|
||||
img.setAttribute('width', String(w))
|
||||
img.setAttribute('height', String(h))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 登录 / SSO / 社交登录页不显示 AI 悬浮入口(兼容子路径) */
|
||||
function isHideAssistantFabPath(path: string): boolean {
|
||||
const p = path || ''
|
||||
if (p === '/login' || p === '/sso' || p === '/social-login') return true
|
||||
if (p.startsWith('/login/') || p.startsWith('/sso/')) return true
|
||||
if (p.startsWith('/social-login')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** 仅由业务页通过 setAiAssistantFabEnabled(true) 开启;默认关闭,避免全站都出现悬浮入口 */
|
||||
const fabEnabled = ref(false)
|
||||
function setAiAssistantFabEnabled(enabled: boolean) {
|
||||
fabEnabled.value = enabled
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const showAssistantFab = computed(
|
||||
() => !isHideAssistantFabPath(route.path) && fabEnabled.value
|
||||
)
|
||||
|
||||
/** 悬浮按钮位置(可拖拽),持久化到 localStorage */
|
||||
const FAB_POS_STORAGE = 'ai-assistant-fab-position'
|
||||
const fabRef = ref<HTMLElement | null>(null)
|
||||
const fabPos = ref<{ left: number; top: number } | null>(null)
|
||||
const fabDragging = ref(false)
|
||||
let fabDragSession: {
|
||||
startX: number
|
||||
startY: number
|
||||
origLeft: number
|
||||
origTop: number
|
||||
moved: boolean
|
||||
} | null = null
|
||||
|
||||
const fabPositionStyle = computed(() => {
|
||||
if (!fabPos.value) return {}
|
||||
return {
|
||||
left: `${fabPos.value.left}px`,
|
||||
top: `${fabPos.value.top}px`,
|
||||
right: 'auto',
|
||||
bottom: 'auto'
|
||||
}
|
||||
})
|
||||
|
||||
function clampFabToViewport() {
|
||||
const el = fabRef.value
|
||||
if (!el) return
|
||||
const pad = 8
|
||||
const w = el.offsetWidth || 200
|
||||
const h = el.offsetHeight || 48
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
if (!fabPos.value) return
|
||||
let { left, top } = fabPos.value
|
||||
// 持久化坐标在视窗外(换屏、缩放、误拖)时恢复默认右下角
|
||||
if (left + w < pad || top + h < pad || left > vw - pad || top > vh - pad) {
|
||||
fabPos.value = null
|
||||
try {
|
||||
localStorage.removeItem(FAB_POS_STORAGE)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return
|
||||
}
|
||||
const nl = Math.max(pad, Math.min(left, vw - w - pad))
|
||||
const nt = Math.max(pad, Math.min(top, vh - h - pad))
|
||||
if (nl !== left || nt !== top) {
|
||||
fabPos.value = { left: nl, top: nt }
|
||||
try {
|
||||
localStorage.setItem(FAB_POS_STORAGE, JSON.stringify(fabPos.value))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onFabResizeClamp() {
|
||||
clampFabToViewport()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAB_POS_STORAGE)
|
||||
if (raw) {
|
||||
const p = JSON.parse(raw) as { left: number; top: number }
|
||||
if (typeof p.left === 'number' && typeof p.top === 'number' && Number.isFinite(p.left) && Number.isFinite(p.top)) {
|
||||
fabPos.value = p
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
nextTick(() => {
|
||||
clampFabToViewport()
|
||||
})
|
||||
window.addEventListener('resize', onFabResizeClamp)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onFabResizeClamp)
|
||||
})
|
||||
|
||||
function onFabPointerMove(e: PointerEvent) {
|
||||
if (!fabDragSession) return
|
||||
const dx = e.clientX - fabDragSession.startX
|
||||
const dy = e.clientY - fabDragSession.startY
|
||||
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) fabDragSession.moved = true
|
||||
const el = fabRef.value
|
||||
if (!el) return
|
||||
let nl = fabDragSession.origLeft + dx
|
||||
let nt = fabDragSession.origTop + dy
|
||||
const pad = 8
|
||||
const w = el.offsetWidth
|
||||
const h = el.offsetHeight
|
||||
nl = Math.max(pad, Math.min(nl, window.innerWidth - w - pad))
|
||||
nt = Math.max(pad, Math.min(nt, window.innerHeight - h - pad))
|
||||
fabPos.value = { left: nl, top: nt }
|
||||
}
|
||||
|
||||
function onFabPointerUp(e: PointerEvent) {
|
||||
if (!fabDragSession) return
|
||||
const moved = fabDragSession.moved
|
||||
fabDragSession = null
|
||||
fabDragging.value = false
|
||||
const el = fabRef.value
|
||||
if (el) {
|
||||
try {
|
||||
el.releasePointerCapture(e.pointerId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
window.removeEventListener('pointermove', onFabPointerMove)
|
||||
window.removeEventListener('pointerup', onFabPointerUp)
|
||||
if (fabPos.value) {
|
||||
try {
|
||||
localStorage.setItem(FAB_POS_STORAGE, JSON.stringify(fabPos.value))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (!moved) {
|
||||
void onFabClick()
|
||||
}
|
||||
}
|
||||
|
||||
function onFabPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (pageLoading.value) return
|
||||
const el = fabRef.value
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
fabDragSession = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origLeft: r.left,
|
||||
origTop: r.top,
|
||||
moved: false
|
||||
}
|
||||
fabDragging.value = true
|
||||
el.setPointerCapture(e.pointerId)
|
||||
window.addEventListener('pointermove', onFabPointerMove)
|
||||
window.addEventListener('pointerup', onFabPointerUp)
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const initialPayload = ref<{
|
||||
file?: File | null
|
||||
query?: string
|
||||
autoSend?: boolean
|
||||
prompt?: string
|
||||
moduleName?: string
|
||||
moduleCode?: string
|
||||
pendingUserText?: string
|
||||
} | null>(null)
|
||||
const chatMessages = ref<import('./useAiAssistant').ChatMessage[]>([])
|
||||
provide(AI_CHAT_MESSAGES_KEY, chatMessages)
|
||||
|
||||
/** 报告单据上下文:模块名、模块编码、截图文件、截图目标 */
|
||||
const reportModuleName = ref<string | null>(null)
|
||||
const reportModuleCode = ref<string | null>(null)
|
||||
const reportScreenshotFile = ref<File | null>(null)
|
||||
const reportScreenshotTarget = ref<string | HTMLElement | null>(null)
|
||||
|
||||
/** 保存时自动截取当前组件页面(Y 轴全部内容),供报告单据使用 */
|
||||
async function captureScreenshotForReport(): Promise<File | null> {
|
||||
const targetRef = reportScreenshotTarget.value
|
||||
if (!targetRef) return null
|
||||
const target = typeof targetRef === 'string' ? document.querySelector(targetRef) : targetRef
|
||||
if (!target || !(target instanceof HTMLElement)) return null
|
||||
|
||||
const TRANSPARENT_PIXEL =
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
||||
const opts = {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
logging: false,
|
||||
ignoreElements: (node: Node) => {
|
||||
const n = node as HTMLElement
|
||||
return n.classList?.contains('ai-assistant-fab') === true
|
||||
},
|
||||
onclone: (clonedDoc: Document, clonedNode: HTMLElement) => {
|
||||
runHtml2CanvasOnClone(clonedDoc, clonedNode, TRANSPARENT_PIXEL)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const w = target.scrollWidth || target.offsetWidth
|
||||
const h = target.scrollHeight || target.offsetHeight
|
||||
const maxDim = Math.max(w, h)
|
||||
const scale = maxDim > 8192 ? 1 : Math.min(2, window.devicePixelRatio || 2)
|
||||
const canvas = await html2canvas(target, { ...opts, scale })
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
resolve(new File([blob], 'screenshot.png', { type: 'image/png' }))
|
||||
},
|
||||
'image/png',
|
||||
0.9
|
||||
)
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('报告截图失败:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
provide<AiAssistantReportContext>(AI_ASSISTANT_REPORT_CONTEXT_KEY, {
|
||||
moduleName: reportModuleName,
|
||||
moduleCode: reportModuleCode,
|
||||
screenshotFile: reportScreenshotFile,
|
||||
captureScreenshot: captureScreenshotForReport
|
||||
})
|
||||
|
||||
const pageLoading = ref(false)
|
||||
function setPageLoading(v: boolean) {
|
||||
pageLoading.value = v
|
||||
}
|
||||
|
||||
/** 当前页面的 AI 任务提示词,由页面通过 setPagePrompt 设置 */
|
||||
const pagePromptRef = ref<string | null>(null)
|
||||
function setPagePrompt(p: string | null) {
|
||||
pagePromptRef.value = p
|
||||
}
|
||||
|
||||
/** 当前页面的模块名称,由页面通过 setPageModuleName 设置,用于报告单据 */
|
||||
function setPageModuleName(name: string | null) {
|
||||
reportModuleName.value = name
|
||||
}
|
||||
|
||||
/** 当前页面的模块编码,由页面通过 setPageModuleCode 设置,用于报告单据 */
|
||||
function setPageModuleCode(code: string | null) {
|
||||
reportModuleCode.value = code
|
||||
}
|
||||
|
||||
/** 当前页面截图目标(选择器或元素),保存报告时自动截取该区域 Y 轴全部内容 */
|
||||
function setScreenshotTarget(target: string | HTMLElement | null) {
|
||||
reportScreenshotTarget.value = target
|
||||
}
|
||||
|
||||
/** 截图并打开对话框(供 AiAssistantMark 等调用) */
|
||||
async function openWithScreenshot(
|
||||
target: string | HTMLElement,
|
||||
options?: { moduleName?: string; moduleCode?: string; prompt?: string; pendingUserText?: string }
|
||||
) {
|
||||
if (pageLoading.value) return
|
||||
await doScreenshot(
|
||||
target,
|
||||
options?.prompt,
|
||||
options?.moduleName,
|
||||
options?.moduleCode,
|
||||
options?.pendingUserText
|
||||
)
|
||||
}
|
||||
|
||||
/** 点击悬浮框:只打开对话框,不截图(仅 AI 分析入口需要截图) */
|
||||
async function onFabClick() {
|
||||
if (pageLoading.value) return
|
||||
const prompt = pagePromptRef.value || undefined
|
||||
const moduleName = reportModuleName.value || undefined
|
||||
const moduleCode = reportModuleCode.value || undefined
|
||||
initialPayload.value = { prompt, autoSend: false, moduleName, moduleCode }
|
||||
reportScreenshotFile.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function doScreenshot(
|
||||
el: string | HTMLElement,
|
||||
prompt?: string,
|
||||
moduleName?: string,
|
||||
moduleCode?: string,
|
||||
pendingUserText?: string
|
||||
) {
|
||||
const target = typeof el === 'string' ? document.querySelector(el) : el
|
||||
if (!target || !(target instanceof HTMLElement)) {
|
||||
ElMessage.warning('未找到截图区域')
|
||||
dialogVisible.value = true
|
||||
return
|
||||
}
|
||||
const payloadPrompt = prompt
|
||||
const payloadModuleName = moduleName
|
||||
const payloadModuleCode = moduleCode
|
||||
const payloadPendingUserText = pendingUserText
|
||||
|
||||
const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
||||
|
||||
const opts = {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
logging: false,
|
||||
ignoreElements: (node: Node) => {
|
||||
const n = node as HTMLElement
|
||||
return n.classList?.contains('ai-assistant-fab') === true
|
||||
},
|
||||
onclone: (clonedDoc: Document, clonedNode: HTMLElement) => {
|
||||
runHtml2CanvasOnClone(clonedDoc, clonedNode, TRANSPARENT_PIXEL)
|
||||
}
|
||||
}
|
||||
|
||||
const tryCapture = async (scale: number = 1): Promise<void> => {
|
||||
const canvas = await html2canvas(target, { ...opts, scale })
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('toBlob失败'))
|
||||
return
|
||||
}
|
||||
const file = new File([blob], 'screenshot.png', { type: 'image/png' })
|
||||
initialPayload.value = {
|
||||
file,
|
||||
autoSend: false,
|
||||
prompt: payloadPrompt,
|
||||
moduleName: payloadModuleName,
|
||||
moduleCode: payloadModuleCode,
|
||||
pendingUserText: payloadPendingUserText
|
||||
}
|
||||
reportScreenshotFile.value = file
|
||||
if (payloadModuleCode) reportModuleCode.value = payloadModuleCode
|
||||
dialogVisible.value = true
|
||||
resolve()
|
||||
},
|
||||
'image/png',
|
||||
0.9
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// html2canvas 会截取完整可滚动内容,用 scroll 尺寸判断
|
||||
const w = target.scrollWidth || target.offsetWidth
|
||||
const h = target.scrollHeight || target.offsetHeight
|
||||
const maxDim = Math.max(w, h)
|
||||
// 浏览器 canvas 单边约 16384px,scale 2 时需 maxDim <= 8192
|
||||
const scale = maxDim > 8192 ? 1 : Math.min(2, window.devicePixelRatio || 2)
|
||||
await tryCapture(scale)
|
||||
} catch (e) {
|
||||
try {
|
||||
await tryCapture(1)
|
||||
} catch (e2) {
|
||||
console.error('截图失败:', e2)
|
||||
ElMessage.error('截图失败,已打开对话框')
|
||||
initialPayload.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(dialogVisible, (v) => {
|
||||
if (!v) {
|
||||
initialPayload.value = null
|
||||
reportScreenshotFile.value = null
|
||||
}
|
||||
})
|
||||
|
||||
provide(AI_ASSISTANT_KEY, {
|
||||
openWithScreenshot,
|
||||
setPageLoading,
|
||||
setPagePrompt,
|
||||
setPageModuleName,
|
||||
setPageModuleCode,
|
||||
setScreenshotTarget,
|
||||
setAiAssistantFabEnabled
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 参考 AIRBT.html:白底、蓝边、圆角药丸、图标蓝圈、hover 放大 */
|
||||
.ai-assistant-fab {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 85px;
|
||||
z-index: 10050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
transition: transform 0.3s, box-shadow 0.3s, opacity 0.3s;
|
||||
}
|
||||
.ai-assistant-fab.is-dragging {
|
||||
cursor: grabbing;
|
||||
transform: none;
|
||||
transition: box-shadow 0.2s, opacity 0.2s;
|
||||
}
|
||||
.ai-assistant-fab:hover:not(.is-loading):not(.is-hidden):not(.is-dragging) {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
/* 抽屉已打开时隐藏悬浮按钮,避免与侧栏内容重叠、重复入口 */
|
||||
.ai-assistant-fab.is-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
.ai-assistant-fab.is-loading {
|
||||
cursor: not-allowed;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.fab-icon-wrap {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
.fab-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
.fab-loading {
|
||||
animation: fab-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes fab-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.fab-text {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
</style>
|
||||
1387
src/components/AiAssistant/AiImageChatDialog.vue
Normal file
1387
src/components/AiAssistant/AiImageChatDialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
2571
src/components/AiAssistant/ReportDocumentModal.vue
Normal file
2571
src/components/AiAssistant/ReportDocumentModal.vue
Normal file
File diff suppressed because it is too large
Load Diff
115
src/components/AiAssistant/index.vue
Normal file
115
src/components/AiAssistant/index.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="ai-assistant-wrapper">
|
||||
<!-- 右下角悬浮按钮 -->
|
||||
<div class="ai-assistant-fab" @click="onFabClick">
|
||||
<Icon icon="ep:chat-dot-round" class="fab-icon" />
|
||||
<span class="fab-text">AI助手</span>
|
||||
</div>
|
||||
|
||||
<!-- AI 图片对话弹窗 -->
|
||||
<AiImageChatDialog
|
||||
v-model="dialogVisible"
|
||||
:initial-payload="initialPayload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import html2canvas from 'html2canvas'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import AiImageChatDialog from './AiImageChatDialog.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'AiAssistant' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 截图目标选择器,如 ".product-dashboard" */
|
||||
target?: string
|
||||
}>(),
|
||||
{ target: 'body' }
|
||||
)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const initialPayload = ref<{ file: File; autoSend?: boolean } | null>(null)
|
||||
|
||||
const onFabClick = async () => {
|
||||
const el = document.querySelector(props.target)
|
||||
if (!el || !(el instanceof HTMLElement)) {
|
||||
ElMessage.warning('未找到截图区域')
|
||||
dialogVisible.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(el, {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
scale: window.devicePixelRatio || 2,
|
||||
ignoreElements: (node) => {
|
||||
const el = node as HTMLElement
|
||||
return el.classList?.contains('ai-assistant-fab') === true
|
||||
}
|
||||
})
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
ElMessage.error('截图失败')
|
||||
dialogVisible.value = true
|
||||
return
|
||||
}
|
||||
const file = new File([blob], 'screenshot.png', { type: 'image/png' })
|
||||
initialPayload.value = {
|
||||
file,
|
||||
autoSend: false
|
||||
}
|
||||
dialogVisible.value = true
|
||||
},
|
||||
'image/png',
|
||||
0.95
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('截图失败:', e)
|
||||
ElMessage.error('截图失败,已打开对话框')
|
||||
initialPayload.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
watch(dialogVisible, (v) => {
|
||||
if (!v) initialPayload.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-assistant-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.ai-assistant-fab {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%);
|
||||
color: #fff;
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.4);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.ai-assistant-fab:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(64, 158, 255, 0.5);
|
||||
}
|
||||
.fab-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
.fab-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
61
src/components/AiAssistant/useAiAssistant.ts
Normal file
61
src/components/AiAssistant/useAiAssistant.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
picPreview?: string
|
||||
/** 待确认:来自卡片 AI 分析,显示取消/分析按钮,点击分析后才发送 */
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
export interface OpenWithScreenshotOptions {
|
||||
/** 模块名称,用于拼接 taskDesc:【模块名】提示词 */
|
||||
moduleName?: string
|
||||
/** 模块编码,如 SupplierPerformance:main */
|
||||
moduleCode?: string
|
||||
/** 模块 AI 提示词,发送时与 moduleName 拼接为 taskDesc 传给 Dify */
|
||||
prompt?: string
|
||||
/** 待确认卡片上的说明文案(默认「分析其中的数据」) */
|
||||
pendingUserText?: string
|
||||
}
|
||||
|
||||
export interface AiAssistantApi {
|
||||
openWithScreenshot: (target: string | HTMLElement, options?: OpenWithScreenshotOptions) => Promise<void>
|
||||
/** 设置页面查询中状态,为 true 时悬浮框禁用并显示加载动画 */
|
||||
setPageLoading: (loading: boolean) => void
|
||||
/** 设置当前页面的 AI 任务提示词,点击悬浮按钮时代入;离开页面时传 null 清除 */
|
||||
setPagePrompt: (prompt: string | null) => void
|
||||
/** 设置当前页面模块名称,用于报告单据;离开页面时传 null 清除 */
|
||||
setPageModuleName: (moduleName: string | null) => void
|
||||
/** 设置当前页面模块编码,用于报告单据;离开页面时传 null 清除 */
|
||||
setPageModuleCode: (moduleCode: string | null) => void
|
||||
/** 设置当前页面截图目标(选择器或元素),保存报告时自动截取该区域 Y 轴全部内容;离开页面时传 null */
|
||||
setScreenshotTarget: (target: string | HTMLElement | null) => void
|
||||
/**
|
||||
* 是否显示全局「AI 决策助手」悬浮入口;默认 false,仅允许展示的页面在 onMounted 设为 true、onUnmounted 设回 false
|
||||
*/
|
||||
setAiAssistantFabEnabled: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
/** 报告单据上下文:模块名、模块编码、截图文件、截图目标、截图方法(供单据弹窗使用) */
|
||||
export interface AiAssistantReportContext {
|
||||
moduleName: Ref<string | null>
|
||||
moduleCode: Ref<string | null>
|
||||
screenshotFile: Ref<File | null>
|
||||
/** 保存时自动截图当前组件页面,返回截图 File,无目标时返回 null */
|
||||
captureScreenshot: () => Promise<File | null>
|
||||
}
|
||||
export const AI_ASSISTANT_REPORT_CONTEXT_KEY: InjectionKey<AiAssistantReportContext> = Symbol(
|
||||
'AiAssistantReportContext'
|
||||
) as InjectionKey<AiAssistantReportContext>
|
||||
|
||||
export const AI_ASSISTANT_KEY: InjectionKey<AiAssistantApi> = Symbol('AiAssistant') as InjectionKey<AiAssistantApi>
|
||||
export const AI_CHAT_MESSAGES_KEY: InjectionKey<Ref<ChatMessage[]>> = Symbol('AiChatMessages') as InjectionKey<Ref<ChatMessage[]>>
|
||||
|
||||
export function useAiAssistant(): AiAssistantApi {
|
||||
const api = inject(AI_ASSISTANT_KEY)
|
||||
if (!api) {
|
||||
throw new Error('useAiAssistant must be used within AiAssistantProvider')
|
||||
}
|
||||
return api
|
||||
}
|
||||
15
src/hooks/web/useAiModulePromptEditor.ts
Normal file
15
src/hooks/web/useAiModulePromptEditor.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { computed } from 'vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
/**
|
||||
* 模块 AI 提示词编辑入口:仅 admin / super_admin 可见(与系统 hasRole 中超管逻辑一致)
|
||||
*/
|
||||
export function useAiModulePromptEditor() {
|
||||
const userStore = useUserStore()
|
||||
const canEditAiModulePrompt = computed(() => {
|
||||
const roles = userStore.getRoles ?? []
|
||||
if (!Array.isArray(roles)) return false
|
||||
return roles.some((r) => r === 'admin' || r === 'super_admin')
|
||||
})
|
||||
return { canEditAiModulePrompt }
|
||||
}
|
||||
396
src/views/ydoyun/aichat/index.vue
Normal file
396
src/views/ydoyun/aichat/index.vue
Normal file
@@ -0,0 +1,396 @@
|
||||
<template>
|
||||
<div class="ai-chat-page">
|
||||
<div class="chat-header">
|
||||
<div class="chat-header-row">
|
||||
<h2>AI 助手</h2>
|
||||
<el-button
|
||||
v-if="loading"
|
||||
link
|
||||
type="danger"
|
||||
@click="abortStream"
|
||||
>
|
||||
<Icon icon="ep:video-pause" />
|
||||
中止
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="hint">粘贴图片(Ctrl+V)或点击上传,输入问题开始与 AI 对话</p>
|
||||
</div>
|
||||
|
||||
<div class="chat-main">
|
||||
<!-- 消息列表 -->
|
||||
<div ref="messageListRef" class="message-list">
|
||||
<div v-if="messages.length === 0 && !loading" class="empty-tip">
|
||||
<Icon icon="ep:chat-dot-round" class="empty-icon" />
|
||||
<p>粘贴图片或上传图片后输入问题,开始对话</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="idx"
|
||||
:class="['message-item', msg.role]"
|
||||
>
|
||||
<div class="message-avatar">
|
||||
<Icon v-if="msg.role === 'user'" icon="ep:user" />
|
||||
<Icon v-else icon="ep:chat-line-round" />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div v-if="msg.picPreview" class="message-images">
|
||||
<img :src="msg.picPreview" class="msg-img" alt="图片" />
|
||||
</div>
|
||||
<div class="message-text">{{ msg.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="message-item assistant">
|
||||
<div class="message-avatar">
|
||||
<Icon icon="ep:chat-line-round" />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-text streaming">
|
||||
{{ streamingText }}
|
||||
<span class="cursor">|</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区 -->
|
||||
<div class="input-area">
|
||||
<div v-if="picFile" class="preview-images">
|
||||
<div class="preview-item" :class="{ 'is-loading': loading }">
|
||||
<img :src="picPreviewUrl" alt="预览" />
|
||||
<span v-show="!loading" class="remove" @click="clearImage">×</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<div class="input-wrap" :class="{ 'is-loading': loading }">
|
||||
<el-input
|
||||
v-model="query"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="loading ? 'AI 正在思考中,请稍候...' : '输入问题...(支持 Ctrl+V 粘贴图片)'"
|
||||
:disabled="loading"
|
||||
@keydown.enter.exact.prevent="!loading && send()"
|
||||
@paste="onPaste"
|
||||
/>
|
||||
<el-upload
|
||||
class="upload-trigger"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:disabled="loading"
|
||||
:before-upload="onFileSelect"
|
||||
>
|
||||
<el-button type="primary" link :disabled="loading">
|
||||
<Icon icon="ep:upload" /> 上传图片
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="loading || !query.trim()"
|
||||
@click="send"
|
||||
>
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { aiChatStream } from '@/api/ydoyun/aichat'
|
||||
|
||||
defineOptions({ name: 'YdoyunAiChat' })
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
picPreview?: string
|
||||
}
|
||||
|
||||
const messageListRef = ref<HTMLElement>()
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const query = ref('')
|
||||
const picFile = ref<File | null>(null)
|
||||
const picPreviewUrl = ref<string>('')
|
||||
const loading = ref(false)
|
||||
const streamAbortController = ref<AbortController | null>(null)
|
||||
const streamingText = ref('')
|
||||
/** 多轮对话会话 ID,由 Dify 返回,后续请求携带以保持上下文 */
|
||||
const conversationId = ref<string>('')
|
||||
|
||||
watch(picFile, (file) => {
|
||||
if (picPreviewUrl.value) {
|
||||
URL.revokeObjectURL(picPreviewUrl.value)
|
||||
picPreviewUrl.value = ''
|
||||
}
|
||||
if (file) {
|
||||
picPreviewUrl.value = URL.createObjectURL(file)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (file) setPicFile(file)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearImage = () => {
|
||||
if (loading.value) return
|
||||
picFile.value = null
|
||||
}
|
||||
|
||||
function abortStream() {
|
||||
streamAbortController.value?.abort()
|
||||
}
|
||||
|
||||
const setPicFile = (file: File) => {
|
||||
if (!file?.type?.startsWith('image/')) return
|
||||
picFile.value = file
|
||||
}
|
||||
|
||||
const onFileSelect = (rawFile: { raw?: File } | File) => {
|
||||
if (loading.value) return false
|
||||
const file = typeof rawFile === 'object' && rawFile?.raw ? rawFile.raw : (rawFile as File)
|
||||
setPicFile(file)
|
||||
return false
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (loading.value) return
|
||||
const q = query.value.trim()
|
||||
if (!q) return
|
||||
|
||||
const file = picFile.value
|
||||
const msgPreviewUrl = file ? URL.createObjectURL(file) : undefined
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
text: q,
|
||||
picPreview: msgPreviewUrl
|
||||
})
|
||||
query.value = ''
|
||||
picFile.value = null
|
||||
streamingText.value = ''
|
||||
|
||||
loading.value = true
|
||||
streamAbortController.value = new AbortController()
|
||||
const signal = streamAbortController.value.signal
|
||||
let fullText = ''
|
||||
|
||||
try {
|
||||
await aiChatStream(
|
||||
q,
|
||||
file,
|
||||
(chunk) => {
|
||||
fullText += chunk
|
||||
streamingText.value = fullText
|
||||
nextTick(() => {
|
||||
messageListRef.value?.scrollTo({
|
||||
top: messageListRef.value.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
})
|
||||
},
|
||||
(err) => {
|
||||
ElMessage.error(err || '请求失败')
|
||||
},
|
||||
(payload) => {
|
||||
loading.value = false
|
||||
streamAbortController.value = null
|
||||
streamingText.value = ''
|
||||
const aborted = payload?.aborted
|
||||
if (fullText && !aborted) {
|
||||
messages.value.push({ role: 'assistant', text: fullText })
|
||||
}
|
||||
},
|
||||
{
|
||||
conversationId: conversationId.value || undefined,
|
||||
onConversationId: (id) => {
|
||||
conversationId.value = id
|
||||
},
|
||||
signal
|
||||
}
|
||||
)
|
||||
} catch (e) {
|
||||
loading.value = false
|
||||
streamAbortController.value = null
|
||||
streamingText.value = ''
|
||||
ElMessage.error('请求异常')
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (picPreviewUrl.value) URL.revokeObjectURL(picPreviewUrl.value)
|
||||
messages.value.forEach((m) => {
|
||||
if (m.picPreview) URL.revokeObjectURL(m.picPreview)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-chat-page {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
height: calc(100vh - 100px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chat-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.chat-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.chat-header h2 {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--el-bg-color-page);
|
||||
}
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
.message-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.message-item.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.message-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--el-fill-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.message-item.user .message-avatar {
|
||||
background: var(--el-color-primary-light-7);
|
||||
}
|
||||
.message-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.message-images {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.msg-img {
|
||||
max-width: 200px;
|
||||
max-height: 150px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.message-text {
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.message-text.streaming {
|
||||
display: inline;
|
||||
}
|
||||
.cursor {
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.input-area {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.preview-images {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.preview-item {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
.preview-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
}
|
||||
.preview-item .remove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--el-color-danger);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.input-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
.input-wrap.is-loading :deep(.el-textarea__inner) {
|
||||
cursor: not-allowed;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
.upload-trigger {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
}
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.input-row :deep(.el-textarea) {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
2270
src/views/ydoyun/difykb/index.vue
Normal file
2270
src/views/ydoyun/difykb/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
14
src/views/ydoyun/report/lijun/reportpage6/aiModuleConfig.ts
Normal file
14
src/views/ydoyun/report/lijun/reportpage6/aiModuleConfig.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* ProductDashboard AI 可分析模块配置
|
||||
* 定义任务时需列举这些模块供 AI 分析
|
||||
*/
|
||||
export const PRODUCT_DASHBOARD_COMPONENT = 'ProductDashboard'
|
||||
|
||||
export const PRODUCT_DASHBOARD_MODULES = [
|
||||
{ key: 'kpi', name: 'KPI 指标卡片', moduleKey: `${PRODUCT_DASHBOARD_COMPONENT}:kpi` },
|
||||
{ key: 'supplier', name: '供应商表现 (Top 10)', moduleKey: `${PRODUCT_DASHBOARD_COMPONENT}:supplier` },
|
||||
{ key: 'pie', name: '中类销售排名 Top 5', moduleKey: `${PRODUCT_DASHBOARD_COMPONENT}:pie` },
|
||||
{ key: 'productList', name: '商品明细列表', moduleKey: `${PRODUCT_DASHBOARD_COMPONENT}:productList` }
|
||||
] as const
|
||||
|
||||
export type ProductDashboardModuleKey = (typeof PRODUCT_DASHBOARD_MODULES)[number]['key']
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:fullscreen="isFullscreen"
|
||||
:width="isFullscreen ? undefined : '560px'"
|
||||
draggable
|
||||
destroy-on-close
|
||||
:class="['ai-prompt-dialog', { 'ai-prompt-resizable': !isFullscreen }]"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<template #header>
|
||||
<div class="ai-prompt-header">
|
||||
<span class="ai-prompt-title">编辑 AI 提示词 - {{ moduleName }}</span>
|
||||
<el-tooltip :content="isFullscreen ? '退出全屏' : '全屏'" placement="top">
|
||||
<el-button link class="fullscreen-btn" @click="isFullscreen = !isFullscreen">
|
||||
<Icon :icon="isFullscreen ? 'ep:copy-document' : 'ep:full-screen'" />
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="模块">
|
||||
<span class="module-key-text">{{ moduleKey }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="提示词">
|
||||
<el-input
|
||||
v-model="form.prompt"
|
||||
type="textarea"
|
||||
:rows="isFullscreen ? 20 : 8"
|
||||
placeholder="请输入 AI 分析任务定义,例如:请分析当前模块的销售数据,重点关注趋势和异常..."
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存提示词</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { AiModulePromptApi } from '@/api/ydoyun/aiModulePrompt'
|
||||
|
||||
defineOptions({ name: 'AiPromptEditDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
moduleKey: string
|
||||
moduleName: string
|
||||
initialPrompt?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [v: boolean]
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const isFullscreen = ref(false)
|
||||
const form = reactive({ prompt: '' })
|
||||
const saving = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
if (v) {
|
||||
form.prompt = props.initialPrompt || ''
|
||||
isFullscreen.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const componentName = props.moduleKey.split(':')[0] || 'ProductDashboard'
|
||||
await AiModulePromptApi.save({
|
||||
componentName,
|
||||
moduleName: props.moduleName,
|
||||
moduleKey: props.moduleKey,
|
||||
prompt: form.prompt
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
visible.value = false
|
||||
emit('saved')
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClosed = () => {
|
||||
form.prompt = ''
|
||||
isFullscreen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-prompt-header {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-prompt-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.fullscreen-btn {
|
||||
padding: 4px;
|
||||
color: var(--el-text-color-regular);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.fullscreen-btn:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.module-key-text {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 让头部成为 flex 行,使全屏按钮与关闭按钮在同一 Y 轴对齐 */
|
||||
.ai-prompt-dialog .el-dialog__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: var(--el-dialog-padding-primary);
|
||||
margin-right: 0;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 关闭按钮随 flex 排列,取消原来的绝对定位偏移 */
|
||||
.ai-prompt-dialog .el-dialog__headerbtn {
|
||||
position: static;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 非全屏时启用 resize(右下角拖拽调整大小) */
|
||||
.ai-prompt-resizable.el-dialog {
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
min-width: 400px;
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.ai-prompt-resizable.el-dialog .el-dialog__body {
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
2251
src/views/ydoyun/report/salesdaily/index.vue
Normal file
2251
src/views/ydoyun/report/salesdaily/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
450
src/views/ydoyun/tagconfig/EditSqlModal.vue
Normal file
450
src/views/ydoyun/tagconfig/EditSqlModal.vue
Normal file
@@ -0,0 +1,450 @@
|
||||
<template>
|
||||
<Dialog :title="'编辑SQL - ' + (tag?.name || '')" v-model="dialogVisible" width="800px" max-height="85vh">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="标签名称">
|
||||
<el-input :model-value="tag?.name" readonly />
|
||||
</el-form-item>
|
||||
<!-- 标签颜色:与生命周期类一致,显示当前颜色并可编辑 -->
|
||||
<el-form-item label="标签颜色">
|
||||
<div class="color-selector-row">
|
||||
<span class="color-current-label">当前颜色:</span>
|
||||
<span
|
||||
class="color-dot-preview"
|
||||
:class="{ 'no-color': !editableColor }"
|
||||
:style="editableColor ? { backgroundColor: editableColor } : {}"
|
||||
:title="editableColor || '未设置'"
|
||||
></span>
|
||||
<span class="color-hex-text">{{ editableColor || '-' }}</span>
|
||||
<div class="color-fixed-selector">
|
||||
<div class="dot-group">
|
||||
<span
|
||||
v-for="opt in COLOR_OPTIONS"
|
||||
:key="opt.hex"
|
||||
:class="['color-dot', 'color-dot-clickable', opt.class, { 'color-dot-active': editableColor === opt.hex }]"
|
||||
:title="opt.hex"
|
||||
@click="setEditableColor(opt.hex)"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="执行SQL脚本" prop="sqlScript">
|
||||
<div class="param-ref-row">
|
||||
<el-button size="small" type="success" plain @click="insertInsertTemplate">
|
||||
插入 {{ insertTableName }} 模板
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
class="btn-ref-color"
|
||||
:style="{ backgroundColor: editableColor || '#409EFF', borderColor: editableColor || '#409EFF', color: '#fff' }"
|
||||
@click="insertParamRef('颜色')"
|
||||
>
|
||||
引用颜色
|
||||
</el-button>
|
||||
<template v-if="paramListWithoutColor.length > 0">
|
||||
<span class="param-ref-label">引用参数:</span>
|
||||
<el-button
|
||||
v-for="p in paramListWithoutColor"
|
||||
:key="p"
|
||||
size="small"
|
||||
type="info"
|
||||
plain
|
||||
@click="insertParamRef(p)"
|
||||
>
|
||||
引用{{ p }}
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
<el-input
|
||||
ref="sqlTextareaRef"
|
||||
v-model="form.sqlScript"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="请输入标签同步时执行的SQL脚本,支持多行。可使用上方按钮插入参数引用,格式为 ${参数1}、${参数2} 等"
|
||||
class="sql-script-area"
|
||||
/>
|
||||
<div class="form-item-hint">该SQL将用于标签计算与同步,请确保语法正确。修改后需点击底部保存按钮统一保存。</div>
|
||||
<!-- SQL 预览:文本框下方,参数代入并高亮 -->
|
||||
<div class="sql-preview-block" v-if="form.sqlScript && paramList.length > 0">
|
||||
<div class="sql-preview-label">SQL 预览(参数已代入):</div>
|
||||
<div class="sql-preview-content" v-html="previewHtml"></div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<!-- 参数输入区(颜色由上方标签颜色选择器编辑,此处不重复展示) -->
|
||||
<el-form-item label="参数值" v-if="paramListWithoutColor.length > 0">
|
||||
<div class="preview-params">
|
||||
<div v-for="p in paramListWithoutColor" :key="p" class="preview-param-row">
|
||||
<span class="preview-param-label">{{ p }}:</span>
|
||||
<el-input
|
||||
v-model="previewParamValues[p]"
|
||||
size="small"
|
||||
placeholder="输入值"
|
||||
class="preview-param-input"
|
||||
@input="updatePreview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="saving">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Tag } from '@/api/ydoyun/tagconfig'
|
||||
import Dialog from '@/components/Dialog/src/Dialog.vue'
|
||||
|
||||
defineOptions({ name: 'EditSqlModal' })
|
||||
|
||||
const PLACEHOLDER_REG = /\$\{([^}]+)\}/g
|
||||
|
||||
const dialogVisible = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{
|
||||
tag: (Tag & { paramDefaults?: Record<string, string>; paramList?: string[] }) | null
|
||||
}>()
|
||||
const tag = computed(() => props.tag)
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', tagId: number, sqlScript: string, sqlScriptResolved: string, paramValues?: Record<string, string>): void
|
||||
}>()
|
||||
|
||||
/** 颜色选项(与 index.vue 生命周期类一致) */
|
||||
const COLOR_OPTIONS = [
|
||||
{ hex: '#409EFF', class: 'dot-blue' },
|
||||
{ hex: '#F56C6C', class: 'dot-red' },
|
||||
{ hex: '#E6A23C', class: 'dot-yellow' },
|
||||
{ hex: '#67C23A', class: 'dot-green' }
|
||||
]
|
||||
|
||||
const form = ref({ sqlScript: '' })
|
||||
const formRef = ref()
|
||||
const sqlTextareaRef = ref()
|
||||
const saving = ref(false)
|
||||
const editableColor = ref('')
|
||||
const previewParamValues = ref<Record<string, string>>({})
|
||||
const previewHtml = ref('')
|
||||
const rules = {
|
||||
sqlScript: [{ required: true, message: '执行SQL脚本不能为空', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
/** 颜色以外的参数列表(颜色由上方标签颜色选择器编辑) */
|
||||
const paramListWithoutColor = computed(() =>
|
||||
paramList.value.filter((p) => p !== '颜色')
|
||||
)
|
||||
|
||||
const setEditableColor = (color: string) => {
|
||||
editableColor.value = color
|
||||
previewParamValues.value = { ...previewParamValues.value, '颜色': color }
|
||||
updatePreview()
|
||||
}
|
||||
|
||||
const INSERT_TABLE_MAP: Record<string, string> = {
|
||||
product: 'ydoyun_tag_product',
|
||||
supplier: 'ydoyun_tag_supplier',
|
||||
store: 'ydoyun_tag_store',
|
||||
member: 'ydoyun_tag_member'
|
||||
}
|
||||
|
||||
const insertTableName = computed(() => {
|
||||
const t = tag.value
|
||||
const type = t?.type || 'product'
|
||||
return INSERT_TABLE_MAP[type] || 'ydoyun_tag_product'
|
||||
})
|
||||
|
||||
const paramList = computed(() => {
|
||||
const t = tag.value
|
||||
const fromTag = (t as any)?.paramList?.length
|
||||
? (t as any).paramList
|
||||
: t?.useParams && t?.params
|
||||
? (t.params || '').split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: []
|
||||
const sql = form.value.sqlScript || ''
|
||||
const fromSql = new Set<string>()
|
||||
let m: RegExpExecArray | null
|
||||
const re = new RegExp(PLACEHOLDER_REG.source, 'g')
|
||||
while ((m = re.exec(sql)) !== null) fromSql.add(m[1])
|
||||
const combined = new Set([...fromTag, ...fromSql])
|
||||
return Array.from(combined)
|
||||
})
|
||||
|
||||
const escapeHtml = (s: string) => {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = s
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
const updatePreview = () => {
|
||||
const sql = form.value.sqlScript || ''
|
||||
const values = previewParamValues.value || {}
|
||||
const parts: string[] = []
|
||||
let lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
const re = new RegExp(PLACEHOLDER_REG.source, 'g')
|
||||
while ((m = re.exec(sql)) !== null) {
|
||||
parts.push(escapeHtml(sql.slice(lastIndex, m.index)))
|
||||
const paramName = m[1]
|
||||
const value = values[paramName] ?? ''
|
||||
parts.push(`<mark class="param-value-mark">${escapeHtml(value || '?' + paramName + '?')}</mark>`)
|
||||
lastIndex = m.index + m[0].length
|
||||
}
|
||||
parts.push(escapeHtml(sql.slice(lastIndex)))
|
||||
previewHtml.value = parts.join('') || escapeHtml('(无内容)')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => tag.value,
|
||||
(t) => {
|
||||
form.value.sqlScript = t?.sqlScript || ''
|
||||
const defaults = (t as any)?.paramDefaults ?? {}
|
||||
const init: Record<string, string> = {}
|
||||
paramList.value.forEach((p) => {
|
||||
init[p] = defaults[p] ?? ''
|
||||
})
|
||||
// 颜色参数:若 SQL 中有 ${颜色} 且未从 defaults 获取到值,则用标签当前颜色
|
||||
if (paramList.value.includes('颜色') && (init['颜色'] === undefined || init['颜色'] === '') && t?.color) {
|
||||
init['颜色'] = t.color
|
||||
}
|
||||
previewParamValues.value = init
|
||||
// 同步到标签颜色选择器(标签颜色字段)
|
||||
editableColor.value = init['颜色'] ?? t?.color ?? '#409EFF'
|
||||
nextTick(updatePreview)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.value.sqlScript,
|
||||
() => updatePreview()
|
||||
)
|
||||
|
||||
const insertAtCursor = (text: string) => {
|
||||
const textarea = sqlTextareaRef.value?.$el?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const sql = form.value.sqlScript || ''
|
||||
form.value.sqlScript = sql.slice(0, start) + text + sql.slice(end)
|
||||
nextTick(() => {
|
||||
textarea.focus()
|
||||
const newPos = start + text.length
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
})
|
||||
} else {
|
||||
form.value.sqlScript = (form.value.sqlScript || '') + text
|
||||
}
|
||||
}
|
||||
|
||||
const insertInsertTemplate = () => {
|
||||
const table = insertTableName.value
|
||||
const template = `INSERT INTO ${table} (code, name, color)
|
||||
SELECT code, name, '\${颜色}' AS color FROM (
|
||||
-- 在此编写您的查询逻辑,将 code、name 替换为实际字段;color 使用 \${颜色} 参数引用,同步时代入标签颜色
|
||||
SELECT '' AS code, '' AS name FROM (SELECT 1) _ LIMIT 0
|
||||
) t
|
||||
`
|
||||
const oldScript = form.value.sqlScript || ''
|
||||
form.value.sqlScript = template + (oldScript ? '\n' + oldScript : '')
|
||||
// 颜色参数默认取标签当前颜色
|
||||
const colorVal = tag.value?.color ?? '#409EFF'
|
||||
editableColor.value = colorVal
|
||||
previewParamValues.value = { ...previewParamValues.value, '颜色': colorVal }
|
||||
nextTick(() => {
|
||||
const textarea = sqlTextareaRef.value?.$el?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(0, 0)
|
||||
}
|
||||
updatePreview()
|
||||
})
|
||||
}
|
||||
|
||||
const insertParamRef = (paramName: string) => {
|
||||
insertAtCursor(`\${${paramName}}`)
|
||||
}
|
||||
|
||||
const substituteParams = (sql: string, values: Record<string, string>) => {
|
||||
return sql.replace(PLACEHOLDER_REG, (_, paramName) => values[paramName] ?? '')
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await formRef.value?.validate()
|
||||
if (!tag.value?.id) return
|
||||
saving.value = true
|
||||
try {
|
||||
const sqlScript = form.value.sqlScript
|
||||
const paramVals = { ...previewParamValues.value }
|
||||
// 始终带上标签颜色,供父组件更新 tagColorValues 及保存
|
||||
if (editableColor.value) paramVals['颜色'] = editableColor.value
|
||||
const sqlScriptResolved = paramList.value.length > 0
|
||||
? substituteParams(sqlScript, paramVals)
|
||||
: sqlScript
|
||||
emit('confirm', tag.value.id, sqlScript, sqlScriptResolved, paramVals)
|
||||
dialogVisible.value = false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.param-ref-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.btn-ref-color {
|
||||
color: #fff !important;
|
||||
}
|
||||
.btn-ref-color:hover,
|
||||
.btn-ref-color:focus {
|
||||
opacity: 0.9;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.param-ref-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.sql-script-area {
|
||||
width: 100%;
|
||||
}
|
||||
.sql-script-area :deep(textarea) {
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.form-item-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.sql-preview-block {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
}
|
||||
.sql-preview-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sql-preview-content {
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.preview-params {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.preview-param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.preview-param-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.preview-param-input {
|
||||
width: 140px;
|
||||
}
|
||||
.preview-result {
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
.preview-result-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.preview-result-content {
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
:deep(.param-value-mark) {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 标签颜色选择器(与生命周期类一致) */
|
||||
.color-selector-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.color-current-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.color-dot-preview {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px #d9d9d9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.color-dot-preview.no-color {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.color-hex-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
.color-fixed-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.dot-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.color-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px #d9d9d9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.color-dot.dot-blue { background-color: #2f54eb; }
|
||||
.color-dot.dot-red { background-color: #f5222d; }
|
||||
.color-dot.dot-yellow { background-color: #fa8c16; }
|
||||
.color-dot.dot-green { background-color: #52c41a; }
|
||||
.color-dot-clickable {
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.color-dot-clickable:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.color-dot-active {
|
||||
box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--el-color-primary);
|
||||
}
|
||||
</style>
|
||||
45
src/views/ydoyun/tagconfig/报表库标签表建表.sql
Normal file
45
src/views/ydoyun/tagconfig/报表库标签表建表.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- ========== 报表库标签表建表脚本(SQL Server 语法)==========
|
||||
-- 同步时由 initTagTables 执行,也可手动执行。color 用于保存标签颜色。
|
||||
-- ============================================================
|
||||
|
||||
-- 删除旧表(可选,会清空数据)
|
||||
-- DROP TABLE IF EXISTS ydoyun_tag_product;
|
||||
-- DROP TABLE IF EXISTS ydoyun_tag_supplier;
|
||||
-- DROP TABLE IF EXISTS ydoyun_tag_store;
|
||||
-- DROP TABLE IF EXISTS ydoyun_tag_member;
|
||||
|
||||
-- 创建产品标签表
|
||||
CREATE TABLE ydoyun_tag_product (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
code NVARCHAR(64) NOT NULL,
|
||||
name NVARCHAR(128) NOT NULL,
|
||||
color NVARCHAR(32) NULL,
|
||||
create_time DATETIME NOT NULL DEFAULT GETDATE()
|
||||
);
|
||||
|
||||
-- 创建供货商标签表
|
||||
CREATE TABLE ydoyun_tag_supplier (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
code NVARCHAR(64) NOT NULL,
|
||||
name NVARCHAR(128) NOT NULL,
|
||||
color NVARCHAR(32) NULL,
|
||||
create_time DATETIME NOT NULL DEFAULT GETDATE()
|
||||
);
|
||||
|
||||
-- 创建门店标签表
|
||||
CREATE TABLE ydoyun_tag_store (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
code NVARCHAR(64) NOT NULL,
|
||||
name NVARCHAR(128) NOT NULL,
|
||||
color NVARCHAR(32) NULL,
|
||||
create_time DATETIME NOT NULL DEFAULT GETDATE()
|
||||
);
|
||||
|
||||
-- 创建会员标签表
|
||||
CREATE TABLE ydoyun_tag_member (
|
||||
id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
code NVARCHAR(64) NOT NULL,
|
||||
name NVARCHAR(128) NOT NULL,
|
||||
color NVARCHAR(32) NULL,
|
||||
create_time DATETIME NOT NULL DEFAULT GETDATE()
|
||||
);
|
||||
168
src/views/ydoyun/tagconfig/表结构.sql
Normal file
168
src/views/ydoyun/tagconfig/表结构.sql
Normal file
@@ -0,0 +1,168 @@
|
||||
-- ========== 变更说明 ==========
|
||||
-- ydoyun_tag: 【新增】sql_script_resolved;【删除】无
|
||||
-- ydoyun_tag_config: 【新增】auto_sync、product_category_ids、sync_warehouse_ids;【删除】无
|
||||
-- ydoyun_tag_sync_history: 同步主表,记录每次同步任务;【删除】无
|
||||
-- ydoyun_tag_sync_detail: 【新增】同步详情表,记录每条标签的执行情况(同步了哪些标签、执行了哪些脚本、成功与否)
|
||||
-- =============================
|
||||
|
||||
-- ----------------------------
|
||||
-- 1. 标准标签表
|
||||
-- ----------------------------
|
||||
create table ydoyun_tag
|
||||
(
|
||||
id bigint auto_increment comment '主键ID'
|
||||
primary key,
|
||||
name varchar(50) not null comment '标签名称',
|
||||
type varchar(20) not null comment '标签类型(product/store/supplier/member)',
|
||||
expression varchar(200) null comment '标签表达式',
|
||||
color varchar(20) null comment '标签颜色',
|
||||
sql_script text null comment 'SQL脚本(含${参数}占位符)',
|
||||
sql_script_resolved text null comment '已拼接参数的SQL,执行时直接使用', -- 【新增】
|
||||
use_params tinyint(1) default 0 not null comment '是否代入参数(0否 1是)',
|
||||
params varchar(255) null comment '参数列表(逗号分隔)',
|
||||
param_values text null comment '参数值 JSON,如 {"参数1":"28","参数2":"90"}',
|
||||
creator varchar(64) default '' null comment '创建者',
|
||||
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
|
||||
updater varchar(64) default '' null comment '更新者',
|
||||
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
|
||||
deleted bit default b'0' not null comment '是否删除',
|
||||
tenant_id bigint default 0 not null comment '租户编号',
|
||||
constraint uk_tenant_name_type unique (tenant_id, name, type)
|
||||
) comment '标准标签表';
|
||||
|
||||
create index idx_tenant_id on ydoyun_tag (tenant_id);
|
||||
create index idx_type on ydoyun_tag (type);
|
||||
|
||||
-- ----------------------------
|
||||
-- 2. 标签同步配置表
|
||||
-- ----------------------------
|
||||
create table ydoyun_tag_config
|
||||
(
|
||||
id bigint auto_increment comment '主键ID'
|
||||
primary key,
|
||||
database_id bigint not null comment '同步数据源ID(ydoyun_report_database.id)',
|
||||
cron_expression varchar(100) null comment '定时任务表达式(CRON)',
|
||||
auto_sync tinyint(1) default 0 not null comment '是否开启自动同步(0否 1是)', -- 【新增】
|
||||
product_category_ids varchar(500) null comment '剔除大类(多选,逗号分隔)', -- 【新增】
|
||||
sync_warehouse_ids varchar(500) null comment '剔除仓库(多选,逗号分隔)', -- 【新增】
|
||||
last_sync_time datetime null comment '上次同步时间',
|
||||
next_sync_time datetime null comment '下次预计同步时间',
|
||||
creator varchar(64) default '' null comment '创建者',
|
||||
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
|
||||
updater varchar(64) default '' null comment '更新者',
|
||||
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
|
||||
deleted bit default b'0' not null comment '是否删除',
|
||||
tenant_id bigint default 0 not null comment '租户编号'
|
||||
) comment '标签同步配置表';
|
||||
|
||||
create index idx_database_id on ydoyun_tag_config (database_id);
|
||||
create index idx_tenant_id on ydoyun_tag_config (tenant_id);
|
||||
|
||||
-- ----------------------------
|
||||
-- 3. 标签同步记录表(主表)
|
||||
-- ----------------------------
|
||||
create table ydoyun_tag_sync_history
|
||||
(
|
||||
id bigint auto_increment comment '主键ID'
|
||||
primary key,
|
||||
tag_config_id bigint not null comment '标签同步配置ID(ydoyun_tag_config.id)',
|
||||
sync_type varchar(20) default 'manual' not null comment '同步类型(manual手动/cron定时)',
|
||||
sync_time datetime default CURRENT_TIMESTAMP not null comment '同步时间',
|
||||
sync_status varchar(20) not null comment '同步状态(success成功/fail失败/partial部分成功)',
|
||||
total_count int default 0 null comment '本次同步标签总数',
|
||||
success_count int default 0 null comment '成功数',
|
||||
fail_count int default 0 null comment '失败数',
|
||||
record_count bigint default 0 null comment '同步记录总数',
|
||||
error_message varchar(500) null comment '汇总错误信息(如有)',
|
||||
creator varchar(64) default '' null comment '创建者',
|
||||
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
|
||||
updater varchar(64) default '' null comment '更新者',
|
||||
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
|
||||
deleted bit default b'0' not null comment '是否删除',
|
||||
tenant_id bigint default 0 not null comment '租户编号'
|
||||
) comment '标签同步记录表(主表)';
|
||||
|
||||
create index idx_sync_time on ydoyun_tag_sync_history (sync_time);
|
||||
create index idx_tag_config_id on ydoyun_tag_sync_history (tag_config_id);
|
||||
create index idx_tenant_id on ydoyun_tag_sync_history (tenant_id);
|
||||
|
||||
-- ----------------------------
|
||||
-- 4. 标签同步详情表(每条标签的执行明细)
|
||||
-- ----------------------------
|
||||
create table ydoyun_tag_sync_detail
|
||||
(
|
||||
id bigint auto_increment comment '主键ID'
|
||||
primary key,
|
||||
sync_history_id bigint not null comment '同步记录ID(ydoyun_tag_sync_history.id)',
|
||||
tag_id bigint not null comment '标签ID(ydoyun_tag.id)',
|
||||
tag_name varchar(50) null comment '标签名称',
|
||||
tag_type varchar(20) null comment '标签类型',
|
||||
sql_executed text null comment '实际执行的SQL脚本',
|
||||
exec_status varchar(20) not null comment '执行状态(success/fail)',
|
||||
record_count bigint default 0 null comment '影响记录数',
|
||||
error_message varchar(1000) null comment '错误信息(失败时)',
|
||||
exec_order int default 0 null comment '执行顺序',
|
||||
creator varchar(64) default '' null comment '创建者',
|
||||
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
|
||||
updater varchar(64) default '' null comment '更新者',
|
||||
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
|
||||
deleted bit default b'0' not null comment '是否删除',
|
||||
tenant_id bigint default 0 not null comment '租户编号'
|
||||
) comment '标签同步详情表(每条标签的执行明细)';
|
||||
|
||||
create index idx_sync_history_id on ydoyun_tag_sync_detail (sync_history_id);
|
||||
create index idx_tag_id on ydoyun_tag_sync_detail (tag_id);
|
||||
create index idx_tenant_id on ydoyun_tag_sync_detail (tenant_id);
|
||||
|
||||
|
||||
-- ========== 已有表时的增量修改 SQL ==========
|
||||
|
||||
-- ydoyun_tag 【新增】sql_script_resolved
|
||||
ALTER TABLE ydoyun_tag ADD COLUMN sql_script_resolved text null comment '已拼接参数的SQL,执行时直接使用' AFTER sql_script;
|
||||
|
||||
-- ydoyun_tag 【新增】param_values
|
||||
ALTER TABLE ydoyun_tag ADD COLUMN param_values text null comment '参数值 JSON,如 {"参数1":"28","参数2":"90"}' AFTER params;
|
||||
|
||||
-- ydoyun_tag_config 【新增】auto_sync、product_category_ids、sync_warehouse_ids
|
||||
ALTER TABLE ydoyun_tag_config ADD COLUMN auto_sync tinyint(1) default 0 not null comment '是否开启自动同步(0否 1是)' AFTER cron_expression;
|
||||
ALTER TABLE ydoyun_tag_config ADD COLUMN product_category_ids varchar(500) null comment '剔除大类(多选,逗号分隔)' AFTER auto_sync;
|
||||
ALTER TABLE ydoyun_tag_config ADD COLUMN sync_warehouse_ids varchar(500) null comment '剔除仓库(多选,逗号分隔)' AFTER auto_sync;
|
||||
|
||||
-- ydoyun_tag_sync_history 【新增】total_count、success_count、fail_count(若原表无则添加)
|
||||
ALTER TABLE ydoyun_tag_sync_history ADD COLUMN total_count int default 0 null comment '本次同步标签总数' AFTER sync_status;
|
||||
ALTER TABLE ydoyun_tag_sync_history ADD COLUMN success_count int default 0 null comment '成功数' AFTER total_count;
|
||||
ALTER TABLE ydoyun_tag_sync_history ADD COLUMN fail_count int default 0 null comment '失败数' AFTER success_count;
|
||||
|
||||
-- ydoyun_tag_sync_detail 【新增】整张表(若不存在则执行下方建表)
|
||||
-- 若已有 ydoyun_tag_sync_history,需先执行上方的 ALTER,再执行下方建表
|
||||
CREATE TABLE IF NOT EXISTS ydoyun_tag_sync_detail
|
||||
(
|
||||
id bigint auto_increment comment '主键ID' primary key,
|
||||
sync_history_id bigint not null comment '同步记录ID(ydoyun_tag_sync_history.id)',
|
||||
tag_id bigint not null comment '标签ID(ydoyun_tag.id)',
|
||||
tag_name varchar(50) null comment '标签名称',
|
||||
tag_type varchar(20) null comment '标签类型',
|
||||
sql_executed text null comment '实际执行的SQL脚本',
|
||||
exec_status varchar(20) not null comment '执行状态(success/fail)',
|
||||
record_count bigint default 0 null comment '影响记录数',
|
||||
error_message varchar(1000) null comment '错误信息(失败时)',
|
||||
exec_order int default 0 null comment '执行顺序',
|
||||
creator varchar(64) default '' null comment '创建者',
|
||||
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
|
||||
updater varchar(64) default '' null comment '更新者',
|
||||
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
|
||||
deleted bit default b'0' not null comment '是否删除',
|
||||
tenant_id bigint default 0 not null comment '租户编号'
|
||||
) comment '标签同步详情表(每条标签的执行明细)';
|
||||
CREATE INDEX idx_sync_history_id ON ydoyun_tag_sync_detail (sync_history_id);
|
||||
CREATE INDEX idx_tag_id ON ydoyun_tag_sync_detail (tag_id);
|
||||
CREATE INDEX idx_tenant_id ON ydoyun_tag_sync_detail (tenant_id);
|
||||
|
||||
|
||||
-- ========== 报表库标签表(ydoyun_tag_*)新增 color 字段 ==========
|
||||
-- 以下表在报表数据库中,用于同步标签数据。若表已存在且需保留数据,可执行以下 SQL Server 语法:
|
||||
-- ALTER TABLE ydoyun_tag_product ADD color NVARCHAR(32) NULL;
|
||||
-- ALTER TABLE ydoyun_tag_supplier ADD color NVARCHAR(32) NULL;
|
||||
-- ALTER TABLE ydoyun_tag_store ADD color NVARCHAR(32) NULL;
|
||||
-- ALTER TABLE ydoyun_tag_member ADD color NVARCHAR(32) NULL;
|
||||
-- 注:执行「初始化标签库」会删除并重建这些表,新表已包含 color 字段。
|
||||
297
src/views/ydoyun/日报表 - 副本1.HTML
Normal file
297
src/views/ydoyun/日报表 - 副本1.HTML
Normal file
@@ -0,0 +1,297 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>销售日报</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f0f2f5;
|
||||
--panel: #ffffff;
|
||||
--primary: #1890ff;
|
||||
--target: #ff4d4f; /* 最终目标线 */
|
||||
--time-marker: #262626; /* 时间进度线 */
|
||||
--text-main: #262626;
|
||||
--text-sub: #8c8c8c;
|
||||
--border: #f0f0f0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text-main);
|
||||
font-family: -apple-system, sans-serif;
|
||||
margin: 0; padding: 15px;
|
||||
display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 15px;
|
||||
}
|
||||
|
||||
/* 顶部导航优化 */
|
||||
.top-header {
|
||||
grid-column: 1 / span 2;
|
||||
background: var(--panel);
|
||||
padding: 8px 15px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
|
||||
margin-bottom: -10px;
|
||||
}
|
||||
|
||||
.weather-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.weather-item {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-right: 15px;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.weather-item:last-child { border-right: none; }
|
||||
.weather-item.today { font-weight: bold; color: var(--primary); }
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 2px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card { background: var(--panel); border-radius: 4px; padding: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.05); }
|
||||
.card-title { font-size: 1rem; font-weight: bold; margin-bottom: 15px; border-left: 4px solid var(--primary); padding-left: 10px; }
|
||||
|
||||
/* 子弹图样式 */
|
||||
.bullet-container { margin-bottom: 20px; }
|
||||
.bullet-graph {
|
||||
position: relative; height: 26px;
|
||||
border-radius: 2px; display: flex; align-items: center; margin-top: 8px;
|
||||
}
|
||||
.bullet-bar {
|
||||
height: 14px; background: var(--primary); position: absolute; left: 0;
|
||||
z-index: 2; transition: width 0.6s ease;
|
||||
}
|
||||
.time-elapsed-line {
|
||||
position: absolute; width: 3px; height: 26px; background: var(--time-marker);
|
||||
z-index: 5; top: 0;
|
||||
}
|
||||
.target-marker {
|
||||
position: absolute; width: 3px; height: 22px; background: var(--target);
|
||||
z-index: 5; top: 2px;
|
||||
}
|
||||
.bullet-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-sub); margin-top: 5px; }
|
||||
|
||||
/* 12宫格 */
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||||
.stat-box { background: #fafafa; padding: 10px; border: 1px solid var(--border); border-radius: 2px; }
|
||||
.stat-box.highlight { border-color: var(--primary); background: #e6f7ff; }
|
||||
.label { font-size: 12px; color: var(--text-sub); display: block; margin-bottom: 4px; }
|
||||
.value { font-size: 18px; font-weight: bold; }
|
||||
.sub-txt { font-size: 11px; color: var(--primary); margin-left: 4px; font-weight: normal; }
|
||||
|
||||
/* 品类表格 */
|
||||
.category-container { grid-column: 1 / span 2; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { background: #fafafa; padding: 1px; border: 1px solid var(--border); }
|
||||
td { padding: 5px; border: 1px solid var(--border); text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="top-header">
|
||||
<div class="weather-bar"> <h2 style="margin:0; color: var(--primary); font-size: 1.2rem; margin-right: 10px;">销售日报</h2>
|
||||
<div class="weather-item"><span>昨日</span> <span>12/18° ☁️</span></div>
|
||||
<div class="weather-item today"><span>今日</span> <span>14/22° ☀️</span></div>
|
||||
<div class="weather-item"><span>3/25</span> <span>15/24° 🌧️</span></div>
|
||||
<div class="weather-item"><span>3/26</span> <span>11/19° ☁️</span></div>
|
||||
<div class="weather-item"><span>3/27</span> <span>10/17° ⛅</span></div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
|
||||
<select class="filter-select">
|
||||
<option>全部店铺 (衣世界)</option>
|
||||
</select>
|
||||
<input type="date" class="filter-select" value="2026-03-24">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">全店销售达成</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 60px; ">
|
||||
|
||||
<div class="bullet-container">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 8px;">
|
||||
<span style="font-size: 13px; color: var(--text-main);">今日目标完成率</span>
|
||||
<span style="color: var(--primary); font-size: 16px; font-weight: 600; font-family: 'Arial';">80.1%</span>
|
||||
</div>
|
||||
<div class="bullet-graph" style="background: #f5f5f5; height: 16px; border-radius: 8px;">
|
||||
<div class="bullet-bar" style="width: 80.1%; height: 16px; border-radius: 8px; box-shadow: 2px 0 4px rgba(24,144,255,0.2);"></div>
|
||||
<div class="time-elapsed-line" style="left: 75%; height: 24px; top: -4px; background: #595959; width: 2px; border-radius: 1px;"></div>
|
||||
<div class="target-marker" style="left: 100%; height: 20px; top: -2px; background: var(--target); width: 3px;"></div>
|
||||
</div>
|
||||
<div class="bullet-labels" style="margin-top: 10px;">
|
||||
<span>0</span>
|
||||
<span style="background: #eee; padding: 2px 6px; border-radius: 10px;">时间进度: 75%</span>
|
||||
<span style="color: var(--target)">目标: 100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bullet-container">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 8px;">
|
||||
<span style="font-size: 13px; color: var(--text-main);">本月累计完成率</span>
|
||||
<span style="color: var(--primary); font-size: 16px; font-weight: 600; font-family: 'Arial';">52.4%</span>
|
||||
</div>
|
||||
<div class="bullet-graph" style="background: #f5f5f5; height: 16px; border-radius: 8px;">
|
||||
<div class="bullet-bar" style="width: 52.4%; height: 16px; border-radius: 8px; opacity: 0.85;"></div>
|
||||
<div class="time-elapsed-line" style="left: 77%; height: 24px; top: -4px; background: #595959; width: 2px; border-radius: 1px;"></div>
|
||||
<div class="target-marker" style="left: 100%; height: 20px; top: -2px; background: var(--target); width: 3px;"></div>
|
||||
</div>
|
||||
<div class="bullet-labels" style="margin-top: 10px;">
|
||||
<span>0</span>
|
||||
<span style="background: #eee; padding: 2px 6px; border-radius: 10px;">时间进度: 77%</span>
|
||||
<span style="color: var(--target)">目标: 100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
<div class="stat-box highlight"><span class="label">当日实收</span><div class="value">40,042</div></div>
|
||||
<div class="stat-box"><span class="label">昨日实收</span><div class="value">42,935</div></div>
|
||||
<div class="stat-box highlight"><span class="label">当日销量</span><div class="value">1,751</div></div>
|
||||
<div class="stat-box"><span class="label">昨日销量</span><div class="value">1,925</div></div>
|
||||
|
||||
<div class="stat-box"><span class="label">同比增长</span><div class="value" style="color:#f5222d">+11.4%</div></div>
|
||||
<div class="stat-box"><span class="label">会员消费 / 会销比</span><div class="value">18,100<span class="sub-txt">/ 45.2%</span></div></div>
|
||||
<div class="stat-box"><span class="label">小票总数 / 客单价</span><div class="value">648<span class="sub-txt">/ ¥61.8</span></div></div>
|
||||
<div class="stat-box"><span class="label">去年同期</span><div class="value">35,946</div></div>
|
||||
|
||||
<div class="stat-box"><span class="label">标准金额</span><div class="value">49,218</div></div>
|
||||
<div class="stat-box"><span class="label">当月累计</span><div class="value">629,412</div></div>
|
||||
<div class="stat-box"><span class="label">当月目标</span><div class="value">1,200,000</div></div>
|
||||
<div class="stat-box"><span class="label">毛利预估</span><div class="value">--</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">时段销售趋势 (平滑曲线图)</div>
|
||||
<div style="position: relative; height: 180px;">
|
||||
<svg viewBox="0 0 1000 120" preserveAspectRatio="none" style="width: 100%; height: 100%;">
|
||||
<defs>
|
||||
<linearGradient id="area" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1890ff; stop-opacity:0.3" />
|
||||
<stop offset="100%" style="stop-color:#1890ff; stop-opacity:0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M0,120 C150,120 250,30 350,25 C450,20 550,110 650,45 C750,-10 850,120 1000,120 L1000,120 L0,120 Z" fill="url(#area)" />
|
||||
<path d="M0,120 C150,120 250,30 350,25 C450,20 550,110 650,45 C750,-10 850,120 1000,120" stroke="#1890ff" stroke-width="3" fill="none" />
|
||||
<line x1="750" y1="0" x2="750" y2="120" stroke="var(--time-marker)" stroke-dasharray="4" stroke-width="1" />
|
||||
</svg>
|
||||
<div style="display:flex; justify-content:space-between; font-size:10px; color:#8c8c8c; margin-top:10px;">
|
||||
<span>8:00</span><span>11:00</span><span>14:00</span><span>17:00 (18:30处线)</span><span>20:00</span><span>23:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card category-container">
|
||||
<div class="card-title">品类全维度深度分析 (实收/标准/正价/特价/对比/目标)</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">品牌品类</th>
|
||||
<th colspan="4">当日概况</th>
|
||||
<th colspan="2">销售分类</th>
|
||||
<th colspan="2">对比昨日</th>
|
||||
<th colspan="4">进度</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>总销量</th>
|
||||
<th>总金额</th>
|
||||
<th>标准金额</th>
|
||||
<th>成本</th>
|
||||
<th>正价(金额/量)</th>
|
||||
<th>特价(金额/量)</th>
|
||||
<th>销量</th>
|
||||
<th>金额</th>
|
||||
<th>月累计金额</th>
|
||||
<th>日目标</th>
|
||||
<th>日目标对比</th>
|
||||
<th>月目标</th>
|
||||
<th>月目标对比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>女装</td><td>193</td><td>10,799</td><td>15,105</td><td>--</td>
|
||||
<td>9,152 / 137</td><td>1,647 / 56</td>
|
||||
<td>185</td><td>9,726</td><td>163,826</td><td>252,000</td>
|
||||
<td>
|
||||
<div style="width:100px; height:10px; background:#eee; position:relative; margin:0 auto;">
|
||||
<div style="width:65%; height:6px; background:var(--primary); margin-top:2px; position:absolute;"></div>
|
||||
<div style="width:2px; height:10px; background:var(--time-marker); position:absolute; left:77%;"></div>
|
||||
</div>
|
||||
</td><td>252,000</td>
|
||||
<td>
|
||||
<div style="width:100px; height:10px; background:#eee; position:relative; margin:0 auto;">
|
||||
<div style="width:65%; height:6px; background:var(--primary); margin-top:2px; position:absolute;"></div>
|
||||
<div style="width:2px; height:10px; background:var(--time-marker); position:absolute; left:77%;"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>男装</td><td>132</td><td>8,908</td><td>10,714</td><td>--</td>
|
||||
<td>6,969 / 79</td><td>1,939 / 53</td>
|
||||
<td>156</td><td>9,299</td><td>136,944</td><td>318,000</td>
|
||||
<td>
|
||||
<div style="width:100px; height:10px; background:#eee; position:relative; margin:0 auto;">
|
||||
<div style="width:43%; height:6px; background:var(--primary); margin-top:2px; position:absolute;"></div>
|
||||
<div style="width:2px; height:10px; background:var(--time-marker); position:absolute; left:77%;"></div>
|
||||
</div>
|
||||
</td><td>252,000</td>
|
||||
<td>
|
||||
<div style="width:100px; height:10px; background:#eee; position:relative; margin:0 auto;">
|
||||
<div style="width:65%; height:6px; background:var(--primary); margin-top:2px; position:absolute;"></div>
|
||||
<div style="width:2px; height:10px; background:var(--time-marker); position:absolute; left:77%;"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>童装</td><td>137</td><td>5,978</td><td>6,914</td><td>--</td>
|
||||
<td>4,666 / 82</td><td>1,196 / 51</td>
|
||||
<td>158</td><td>6,926</td><td>54,634</td><td>120,000</td>
|
||||
<td>
|
||||
<div style="width:100px; height:10px; background:#eee; position:relative; margin:0 auto;">
|
||||
<div style="width:45%; height:6px; background:var(--primary); margin-top:2px; position:absolute;"></div>
|
||||
<div style="width:2px; height:10px; background:var(--time-marker); position:absolute; left:77%;"></div>
|
||||
</div>
|
||||
</td><td>252,000</td>
|
||||
<td>
|
||||
<div style="width:100px; height:10px; background:#eee; position:relative; margin:0 auto;">
|
||||
<div style="width:65%; height:6px; background:var(--primary); margin-top:2px; position:absolute;"></div>
|
||||
<div style="width:2px; height:10px; background:var(--time-marker); position:absolute; left:77%;"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold">总计</td>
|
||||
<td style="font-weight: bold">1,751</td>
|
||||
<td style="font-weight: bold">40,042</td>
|
||||
<td>--</td><td>--</td>
|
||||
<td>31,617</td><td>8,425</td>
|
||||
<td>1,925</td><td>42,935</td>
|
||||
<td>629,412</td><td>1,200,000</td><td>--</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user