Initial qiwei secondary development handoff

This commit is contained in:
2026-06-23 21:11:20 +08:00
commit 858cb68f4f
207 changed files with 52782 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,574 @@
<template>
<div class="knowledge-page">
<section class="knowledge-toolbar">
<div>
<h2>售后知识库</h2>
<p>沉淀已处理售后案例并同步到自动客服知识检索</p>
</div>
<div class="toolbar-actions">
<button class="ghost-btn" @click="loadAll" :disabled="busy">刷新</button>
</div>
</section>
<section class="status-grid">
<div class="metric">
<span>已处理案例</span>
<strong>{{ cases.length }}</strong>
</div>
<div class="metric">
<span> Excel 归档</span>
<strong>{{ pendingCount }}</strong>
</div>
<div class="metric">
<span>Excel 表格</span>
<strong>{{ archives.length }}</strong>
</div>
<div class="metric wide">
<span>知识目录</span>
<strong class="path-text">{{ knowledgeDir }}</strong>
</div>
</section>
<el-tabs v-model="activeTab" class="knowledge-tabs">
<el-tab-pane label="已处理案例" name="cases">
<section class="filter-row">
<el-input v-model="caseFilters.keyword" clearable placeholder="搜索客户、群聊、问题、处理方案" />
<el-select v-model="caseFilters.engineer" class="filter-select" filterable>
<el-option label="全部工程师" value="all" />
<el-option label="未分配" value="unassigned" />
<el-option v-for="item in engineerOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="caseFilters.room" class="filter-select" filterable>
<el-option label="全部群聊" value="all" />
<el-option v-for="item in roomOptions" :key="item" :label="item" :value="item" />
</el-select>
</section>
<section class="table-panel">
<el-table :data="filteredCases" border stripe height="calc(100vh - 405px)" empty-text="暂无已处理知识案例">
<el-table-column prop="resolvedAt" label="处理时间" width="150" fixed>
<template #default="{ row }">{{ formatDateTime(row.resolvedAt) }}</template>
</el-table-column>
<el-table-column prop="roomName" label="群聊" width="170" />
<el-table-column prop="customerName" label="客户" width="120" />
<el-table-column prop="issueContent" label="问题" min-width="240">
<template #default="{ row }"><div class="multiline">{{ row.issueContent || '-' }}</div></template>
</el-table-column>
<el-table-column prop="resolutionContent" label="最终处理方案" min-width="280">
<template #default="{ row }"><div class="multiline strong-text">{{ row.resolutionContent || '-' }}</div></template>
</el-table-column>
<el-table-column label="负责人" width="150">
<template #default="{ row }">{{ engineerName(row) }}</template>
</el-table-column>
<el-table-column label="知识文件" min-width="260">
<template #default="{ row }">
<span class="path-value">{{ row.markdownPath || '-' }}</span>
<em v-if="row.missingMarkdown" class="missing">文件缺失</em>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<button class="ghost-btn small-btn" @click="editCase(row)">编辑</button>
<button class="ghost-btn small-btn" @click="openCase(row)">打开</button>
</template>
</el-table-column>
</el-table>
</section>
</el-tab-pane>
<el-tab-pane label="Excel归档" name="archives">
<section class="archive-actions">
<button class="primary-btn" @click="archivePending" :disabled="busy || pendingCount === 0">保存本次新增</button>
<button class="ghost-btn" @click="openArchiveFolder" :disabled="busy">打开目录</button>
</section>
<section class="table-panel">
<el-table :data="archives" border stripe height="calc(100vh - 405px)" empty-text="暂无知识库表格">
<el-table-column prop="displayTime" label="保存时间" width="170" fixed>
<template #default="{ row }">{{ row.displayTime || formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="fileName" label="Excel 文件" min-width="260">
<template #default="{ row }">
<div class="file-cell">
<span>{{ row.fileName }}</span>
<em v-if="row.missingFile">文件缺失</em>
</div>
</template>
</el-table-column>
<el-table-column prop="issueCount" label="问题数" width="110" />
<el-table-column prop="path" label="路径" min-width="360">
<template #default="{ row }"><span class="path-value">{{ row.path }}</span></template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<button class="ghost-btn small-btn" @click="openArchive(row.path)">打开</button>
</template>
</el-table-column>
</el-table>
</section>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="editVisible" title="编辑最终处理方案" width="680px" destroy-on-close>
<div class="edit-dialog">
<p>{{ editingCase.issueContent || '-' }}</p>
<el-input v-model="editingResolution" type="textarea" :rows="8" placeholder="填写工程师确认后的最终处理方案" />
</div>
<template #footer>
<button class="ghost-btn" @click="editVisible = false" :disabled="busy">取消</button>
<button class="primary-btn" @click="saveCase" :disabled="busy || !editingResolution.trim()">保存</button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
ArchivePendingAfterSalesIssues,
GetPendingAfterSalesArchiveSummary,
ListAfterSalesKnowledgeArchives,
ListAfterSalesKnowledgeCases,
RevealAfterSalesKnowledgeArchive,
RevealAfterSalesKnowledgeCase,
UpdateAfterSalesKnowledgeCase
} from '../../wailsjs/go/main/App.js'
const activeTab = ref('cases')
const archives = ref([])
const cases = ref([])
const summary = ref({})
const busy = ref(false)
const editVisible = ref(false)
const editingCase = ref({})
const editingResolution = ref('')
const caseFilters = reactive({
keyword: '',
engineer: 'all',
room: 'all'
})
const pendingCount = computed(() => Number(summary.value?.pendingCount || 0))
const knowledgeDir = computed(() => {
const firstPath = cases.value.find(item => item.markdownPath)?.markdownPath || ''
const archivePath = archives.value.find(item => item.path)?.path || ''
const path = firstPath || archivePath
const index = Math.max(path.lastIndexOf('\\'), path.lastIndexOf('/'))
return index > 0 ? path.slice(0, index) : 'config\\knowledge\\after_sales_cases'
})
const engineerOptions = computed(() => uniqueSorted(cases.value.map(engineerName).filter(item => item && item !== '-')))
const roomOptions = computed(() => uniqueSorted(cases.value.map(item => item.roomName).filter(Boolean)))
const filteredCases = computed(() => {
const q = caseFilters.keyword.trim().toLowerCase()
return cases.value
.filter(item => {
if (caseFilters.engineer === 'all') return true
if (caseFilters.engineer === 'unassigned') return engineerName(item) === '-'
return engineerName(item) === caseFilters.engineer
})
.filter(item => caseFilters.room === 'all' || item.roomName === caseFilters.room)
.filter(item => {
if (!q) return true
return [item.roomName, item.customerName, item.issueContent, item.aiSuggestion, item.resolutionContent, engineerName(item)]
.some(text => String(text || '').toLowerCase().includes(q))
})
})
function unwrapResult(result) {
if (!result || typeof result !== 'object') {
return { success: Boolean(result), message: '', data: result }
}
return {
success: result.success !== false,
message: String(result.message || result.error || ''),
data: result.data
}
}
async function loadCases() {
const result = unwrapResult(await ListAfterSalesKnowledgeCases())
if (!result.success) {
ElMessage.error(result.message || '加载已处理案例失败')
cases.value = []
return
}
cases.value = Array.isArray(result.data) ? result.data : []
}
async function loadArchives() {
const result = unwrapResult(await ListAfterSalesKnowledgeArchives())
if (!result.success) {
ElMessage.error(result.message || '加载 Excel 归档失败')
archives.value = []
return
}
archives.value = Array.isArray(result.data) ? result.data : []
}
async function loadSummary() {
const result = unwrapResult(await GetPendingAfterSalesArchiveSummary())
summary.value = result.success ? (result.data || {}) : {}
}
async function loadAll() {
busy.value = true
try {
await Promise.all([loadCases(), loadArchives(), loadSummary()])
} finally {
busy.value = false
}
}
async function archivePending() {
busy.value = true
try {
const result = unwrapResult(await ArchivePendingAfterSalesIssues())
if (!result.success) {
ElMessage.error(result.message || '保存到 Excel 归档失败')
return
}
ElMessage.success(result.message || '已保存到 Excel 归档')
await Promise.all([loadArchives(), loadSummary()])
} catch (err) {
ElMessage.error(`保存到 Excel 归档失败: ${err.message || err}`)
} finally {
busy.value = false
}
}
function editCase(row) {
editingCase.value = row
editingResolution.value = row.resolutionContent || ''
editVisible.value = true
}
async function saveCase() {
busy.value = true
try {
const result = unwrapResult(await UpdateAfterSalesKnowledgeCase(editingCase.value.issueId, editingResolution.value.trim()))
if (!result.success) {
ElMessage.error(result.message || '保存案例失败')
return
}
ElMessage.success('知识案例已更新,知识索引正在后台更新')
editVisible.value = false
await loadCases()
} catch (err) {
ElMessage.error(`保存案例失败: ${err.message || err}`)
} finally {
busy.value = false
}
}
async function openCase(row) {
try {
const result = await RevealAfterSalesKnowledgeCase(row.issueId || '')
const ok = Array.isArray(result) ? result[0] : Boolean(result?.success ?? result)
const msg = Array.isArray(result) ? result[1] : String(result?.message || '')
if (!ok) ElMessage.error(msg || '打开失败')
} catch (err) {
ElMessage.error(`打开失败: ${err.message || err}`)
}
}
async function openArchive(path) {
try {
const result = await RevealAfterSalesKnowledgeArchive(path || '')
const ok = Array.isArray(result) ? result[0] : Boolean(result)
const msg = Array.isArray(result) ? result[1] : ''
if (!ok) ElMessage.error(msg || '打开失败')
} catch (err) {
ElMessage.error(`打开失败: ${err.message || err}`)
}
}
function openArchiveFolder() {
openArchive('')
}
function engineerName(row) {
return row.assignedEngineerName || row.assignedEngineerId || '-'
}
function uniqueSorted(items) {
return [...new Set(items.map(item => String(item || '').trim()).filter(Boolean))]
.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
}
function formatDateTime(value) {
const text = String(value || '')
if (!text) return '-'
const date = new Date(text)
if (Number.isNaN(date.getTime())) return text
const pad = number => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
onMounted(loadAll)
</script>
<style scoped>
.knowledge-page {
color: #18282d;
padding: 18px;
background: #f5f7f8;
border: 1px solid #dfe5e8;
border-radius: 8px;
}
.knowledge-toolbar,
.toolbar-actions,
.filter-row,
.archive-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.knowledge-toolbar {
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
}
.knowledge-toolbar h2 {
margin: 0 0 6px;
font-size: 24px;
}
.knowledge-toolbar p {
margin: 0;
color: #667085;
font-size: 13px;
}
.status-grid {
display: grid;
grid-template-columns: 150px 150px 150px minmax(260px, 1fr);
gap: 12px;
margin-bottom: 14px;
}
.metric {
min-width: 0;
padding: 14px;
border: 1px solid #dfe5e8;
border-radius: 8px;
background: #fff;
}
.metric span {
display: block;
margin-bottom: 8px;
color: #667085;
font-size: 12px;
}
.metric strong {
display: block;
font-size: 22px;
}
.path-text,
.path-value {
display: block;
overflow: hidden;
color: #475467;
font-family: Consolas, monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.knowledge-tabs {
padding: 12px;
border: 1px solid #dfe5e8;
border-radius: 8px;
background: #fff;
}
.filter-row,
.archive-actions {
margin-bottom: 12px;
}
.filter-row .el-input {
width: 320px;
}
.filter-select {
width: 180px;
}
.primary-btn,
.ghost-btn {
min-height: 34px;
border-radius: 6px;
padding: 0 12px;
font-weight: 700;
cursor: pointer;
}
.primary-btn {
border: 1px solid #16706d;
background: #16706d;
color: #fff;
}
.ghost-btn {
border: 1px solid #16706d;
background: #fff;
color: #16706d;
}
.small-btn {
min-height: 30px;
padding: 0 9px;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.table-panel {
min-height: 320px;
}
.multiline {
white-space: pre-wrap;
line-height: 1.5;
color: #24363b;
}
.strong-text {
font-weight: 600;
}
.missing,
.file-cell em {
display: block;
margin-top: 4px;
color: #b42318;
font-size: 12px;
font-style: normal;
}
.edit-dialog p {
margin: 0 0 12px;
color: #475467;
line-height: 1.5;
white-space: pre-wrap;
}
@media (max-width: 1080px) {
.status-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-row .el-input,
.filter-select {
width: 100%;
}
}
/* Command center skin */
.knowledge-page {
color: var(--cmd-text);
border-color: var(--cmd-line);
background: transparent;
}
.knowledge-toolbar,
.metric,
.knowledge-tabs {
border-color: var(--cmd-line);
background: var(--cmd-panel);
box-shadow: var(--cmd-shadow);
}
.knowledge-toolbar {
position: relative;
overflow: hidden;
padding: 18px;
border: 1px solid var(--cmd-line);
border-radius: var(--cmd-radius);
background:
linear-gradient(135deg, rgba(22, 49, 60, 0.9), rgba(8, 20, 26, 0.96)),
var(--cmd-panel);
}
.knowledge-toolbar::before,
.metric::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: 2px;
background: linear-gradient(90deg, var(--cmd-cyan), transparent);
}
.knowledge-toolbar h2 {
color: var(--cmd-text);
}
.knowledge-toolbar p,
.metric span,
.edit-dialog p {
color: var(--cmd-text-soft);
}
.metric {
position: relative;
overflow: hidden;
}
.metric strong {
color: var(--cmd-text);
text-shadow: 0 0 18px rgba(72, 240, 220, 0.12);
}
.path-text,
.path-value {
color: #9fd9df;
}
.knowledge-tabs {
background: rgba(8, 20, 26, 0.92);
}
.multiline {
color: var(--cmd-text-soft);
}
.strong-text {
color: var(--cmd-text);
}
.missing,
.file-cell em {
color: var(--cmd-red);
}
.edit-dialog :deep(.el-textarea__inner) {
color: var(--cmd-text);
background: rgba(5, 15, 20, 0.94);
border-color: rgba(72, 240, 220, 0.24);
}
.primary-btn {
color: #031316;
border-color: rgba(72, 240, 220, 0.82);
background: linear-gradient(135deg, var(--cmd-cyan), var(--cmd-blue));
box-shadow: 0 0 20px rgba(72, 240, 220, 0.16);
}
.ghost-btn {
color: var(--cmd-cyan);
border-color: rgba(72, 240, 220, 0.38);
background: rgba(9, 23, 29, 0.84);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,623 @@
<template>
<div class="kingdee-page">
<section class="kingdee-header">
<div>
<h2>ERP监听</h2>
<p>监听金蝶销售订单排产状态首次完成排产时自动通知对应会话</p>
</div>
<div class="header-actions">
<button class="primary-btn" @click="saveConfig" :disabled="busy">保存配置</button>
<button class="ghost-btn" @click="testConnection" :disabled="busy">测试连接</button>
<button class="ghost-btn" @click="runOnce" :disabled="busy">手动扫描</button>
<button class="ghost-btn" @click="loadAll" :disabled="busy">刷新</button>
</div>
</section>
<section class="status-grid">
<div class="metric">
<span>监听状态</span>
<strong :class="status.running ? 'ok' : 'muted'">{{ status.running ? '运行中' : '未运行' }}</strong>
</div>
<div class="metric">
<span>累计扫描</span>
<strong>{{ status.totalPolled || 0 }}</strong>
</div>
<div class="metric">
<span>已通知</span>
<strong class="ok">{{ status.totalNotified || 0 }}</strong>
</div>
<div class="metric">
<span>未映射</span>
<strong class="warn">{{ status.totalUnmapped || 0 }}</strong>
</div>
<div class="metric">
<span>上次扫描</span>
<strong>{{ formatTime(status.lastPollAt) }}</strong>
</div>
</section>
<div v-if="message" class="inline-message" :class="{ error: messageType === 'error' }">{{ message }}</div>
<section class="kingdee-layout">
<div class="config-column">
<section class="kingdee-panel">
<div class="panel-heading">
<h3>金蝶连接</h3>
<span>{{ config.enabled ? '已开启' : '已关闭' }}</span>
</div>
<div class="form-grid">
<label class="switch-row">
<span>开启监听</span>
<input type="checkbox" v-model="config.enabled">
</label>
<label>
<span>服务地址</span>
<input v-model.trim="config.baseUrl" placeholder="https://example.com/k3cloud">
</label>
<label>
<span>账套ID</span>
<input v-model.trim="config.acctId">
</label>
<label>
<span>用户名</span>
<input v-model.trim="config.username">
</label>
<label>
<span>密码</span>
<input v-model="config.password" type="password" autocomplete="new-password">
</label>
<label>
<span>语言/区域</span>
<input v-model.number="config.lcid" type="number" min="1">
</label>
</div>
</section>
<section class="kingdee-panel">
<div class="panel-heading">
<h3>监听规则</h3>
<span>{{ config.pollIntervalSeconds || 60 }} </span>
</div>
<div class="form-grid">
<label>
<span>轮询间隔</span>
<input v-model.number="config.pollIntervalSeconds" type="number" min="10">
</label>
<label>
<span>销售订单表单编码</span>
<input v-model.trim="config.formId">
</label>
<label>
<span>订单号字段</span>
<input v-model.trim="config.billNoFieldKey">
</label>
<label>
<span>订单ID字段</span>
<input v-model.trim="config.orderIdFieldKey">
</label>
<label>
<span>客户编码字段</span>
<input v-model.trim="config.customerFieldKey">
</label>
<label>
<span>排产状态字段</span>
<input v-model.trim="config.statusFieldKey">
</label>
<label>
<span>完成状态值</span>
<input v-model.trim="config.completedValue">
</label>
<label>
<span>修改时间字段</span>
<input v-model.trim="config.modifyTimeFieldKey">
</label>
</div>
<label class="full-field">
<span>通知模板</span>
<textarea v-model="config.notifyTemplate" rows="3"></textarea>
</label>
</section>
</div>
<div class="config-column">
<section class="kingdee-panel">
<div class="panel-heading">
<h3>通知映射</h3>
<button class="small-btn" type="button" @click="addMapping">新增映射</button>
</div>
<div class="mapping-list">
<div class="mapping-header">
<span>ERP客户编码</span>
<span>机器人账号</span>
<span>通知会话ID</span>
<span>备注</span>
<span></span>
</div>
<div v-for="(row, index) in mappingRows" :key="row.localId" class="mapping-row">
<input v-model.trim="row.customerNumber">
<input v-model.trim="row.robotId">
<input v-model.trim="row.conversationId">
<input v-model.trim="row.remark">
<button class="icon-btn" type="button" @click="removeMapping(index)"></button>
</div>
<p v-if="mappingRows.length === 0" class="empty-text">暂无通知映射</p>
</div>
</section>
<section class="kingdee-panel">
<div class="panel-heading">
<h3>运行记录</h3>
<span>{{ status.status || 'stopped' }}</span>
</div>
<div class="record-block">
<h4>最近通知</h4>
<div v-if="recentNotices.length === 0" class="empty-text">暂无通知记录</div>
<div v-for="item in recentNotices" :key="`${item.orderKey}-${item.notifiedAt}`" class="record-item">
<strong>{{ item.billNo || item.orderKey }}</strong>
<span>{{ item.customerNumber }} / {{ formatTime(item.notifiedAt) }}</span>
<p>{{ item.message }}</p>
</div>
</div>
<div class="record-block">
<h4>最近错误</h4>
<div v-if="recentErrors.length === 0" class="empty-text">暂无错误</div>
<div v-for="item in recentErrors" :key="`${item.message}-${item.createdAt}`" class="record-item error">
<strong>{{ item.billNo || item.customerNumber || '系统' }}</strong>
<span>{{ formatTime(item.createdAt) }}</span>
<p>{{ item.message }}</p>
</div>
</div>
</section>
</div>
</section>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import {
GetKingdeeMonitorConfig,
GetKingdeeMonitorStatus,
RunKingdeeMonitorOnce,
SaveKingdeeMonitorConfig,
TestKingdeeMonitorConnection
} from '../../wailsjs/go/main/App.js'
const defaultTemplate = '您好,您的订单 {{billNo}} 已完成排产,后续我们会继续跟进生产进度。'
const busy = ref(false)
const message = ref('')
const messageType = ref('success')
const status = ref({})
const mappingRows = ref([])
const config = ref(defaultConfig())
const recentNotices = computed(() => status.value.recentNotices || [])
const recentErrors = computed(() => status.value.recentErrors || [])
function defaultConfig() {
return {
enabled: false,
baseUrl: '',
acctId: '',
username: '',
password: '',
lcid: 2052,
pollIntervalSeconds: 60,
formId: 'SAL_SaleOrder',
billNoFieldKey: 'FBillNo',
orderIdFieldKey: 'FID',
customerFieldKey: 'FCustId.FNumber',
statusFieldKey: '',
completedValue: '排产已完成',
modifyTimeFieldKey: 'FModifyDate',
notifyTemplate: defaultTemplate,
customerMappings: {}
}
}
function setMessage(text, type = 'success') {
message.value = text || ''
messageType.value = type
}
async function loadAll() {
busy.value = true
try {
await Promise.all([loadConfig(), loadStatus()])
} finally {
busy.value = false
}
}
async function loadConfig() {
const result = await GetKingdeeMonitorConfig()
if (result?.success === false) {
setMessage(result.message || '加载金蝶配置失败', 'error')
return
}
const data = result?.data || result || {}
config.value = { ...defaultConfig(), ...data }
mappingRows.value = mappingsToRows(config.value.customerMappings || {})
}
async function loadStatus() {
const result = await GetKingdeeMonitorStatus()
if (result?.success === false) {
setMessage(result.message || '加载监听状态失败', 'error')
return
}
status.value = result?.data || {}
}
async function saveConfig() {
busy.value = true
try {
const payload = normalizedConfig()
const result = await SaveKingdeeMonitorConfig(JSON.stringify(payload))
const [ok, msg] = normalizeTupleResult(result)
if (!ok) {
setMessage(msg || '保存失败', 'error')
return
}
config.value = payload
setMessage('金蝶监听配置已保存')
await loadStatus()
} catch (err) {
setMessage(`保存失败: ${err.message || err}`, 'error')
} finally {
busy.value = false
}
}
async function testConnection() {
busy.value = true
try {
const result = await TestKingdeeMonitorConnection(JSON.stringify(normalizedConfig()))
if (result?.success === false) {
setMessage(result.message || '连接失败', 'error')
return
}
setMessage(result?.message || '金蝶连接正常')
} catch (err) {
setMessage(`连接失败: ${err.message || err}`, 'error')
} finally {
busy.value = false
}
}
async function runOnce() {
busy.value = true
try {
const result = await RunKingdeeMonitorOnce()
if (result?.success === false) {
setMessage(result.message || '扫描失败', 'error')
} else {
const data = result?.data || {}
setMessage(`扫描完成:查询 ${data.polled || 0} 条,通知 ${data.notified || 0}`)
}
await loadStatus()
} catch (err) {
setMessage(`扫描失败: ${err.message || err}`, 'error')
} finally {
busy.value = false
}
}
function normalizedConfig() {
return {
...config.value,
pollIntervalSeconds: Number(config.value.pollIntervalSeconds || 60),
lcid: Number(config.value.lcid || 2052),
customerMappings: rowsToMappings(mappingRows.value)
}
}
function mappingsToRows(mappings) {
return Object.entries(mappings || {}).map(([customerNumber, item], index) => ({
localId: `${customerNumber}-${index}-${Date.now()}`,
customerNumber,
robotId: item?.robotId || '',
conversationId: item?.conversationId || '',
remark: item?.remark || ''
}))
}
function rowsToMappings(rows) {
const mappings = {}
for (const row of rows) {
const customerNumber = String(row.customerNumber || '').trim()
if (!customerNumber) continue
mappings[customerNumber] = {
robotId: String(row.robotId || '').trim(),
conversationId: String(row.conversationId || '').trim(),
remark: String(row.remark || '').trim()
}
}
return mappings
}
function addMapping() {
mappingRows.value.push({
localId: `new-${Date.now()}-${mappingRows.value.length}`,
customerNumber: '',
robotId: '',
conversationId: '',
remark: ''
})
}
function removeMapping(index) {
mappingRows.value.splice(index, 1)
}
function normalizeTupleResult(result) {
if (Array.isArray(result)) return [Boolean(result[0]), result[1] || '']
if (result === true) return [true, 'success']
if (result && typeof result === 'object') return [result.success !== false, result.message || '']
return [Boolean(result), '']
}
function formatTime(value) {
const numeric = Number(value || 0)
if (!numeric) return '-'
return new Date(numeric * 1000).toLocaleString()
}
onMounted(loadAll)
</script>
<style scoped>
.kingdee-page {
color: #d6eef2;
min-width: 920px;
}
.kingdee-header,
.kingdee-panel,
.metric {
background: rgba(7, 24, 30, 0.74);
border: 1px solid rgba(72, 240, 220, 0.22);
border-radius: 8px;
}
.kingdee-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
margin-bottom: 14px;
}
.kingdee-header h2,
.panel-heading h3,
.record-block h4 {
margin: 0;
}
.kingdee-header p {
margin: 6px 0 0;
color: #91b5bd;
}
.header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.primary-btn,
.ghost-btn,
.small-btn,
.icon-btn {
border: 1px solid rgba(72, 240, 220, 0.45);
border-radius: 6px;
color: #e9ffff;
background: rgba(20, 70, 78, 0.78);
cursor: pointer;
height: 34px;
padding: 0 12px;
}
.primary-btn {
background: linear-gradient(135deg, #34e7d5, #58a6ff);
color: #062027;
font-weight: 700;
}
.ghost-btn,
.small-btn,
.icon-btn {
background: rgba(10, 28, 34, 0.86);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.status-grid {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.metric {
padding: 12px 14px;
}
.metric span {
display: block;
color: #8fb1b8;
font-size: 13px;
margin-bottom: 6px;
}
.metric strong {
font-size: 22px;
}
.ok {
color: #3df2d0;
}
.warn {
color: #ffd166;
}
.muted {
color: #9fb3b9;
}
.inline-message {
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid rgba(72, 240, 220, 0.28);
background: rgba(18, 66, 64, 0.7);
}
.inline-message.error {
border-color: rgba(255, 113, 113, 0.42);
background: rgba(78, 26, 34, 0.78);
}
.kingdee-layout {
display: grid;
grid-template-columns: minmax(420px, 1fr) minmax(420px, 1fr);
gap: 14px;
}
.config-column {
display: flex;
flex-direction: column;
gap: 14px;
}
.kingdee-panel {
padding: 16px;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.panel-heading span {
color: #72f0de;
font-size: 13px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
label,
.full-field {
display: flex;
flex-direction: column;
gap: 6px;
color: #a9c8ce;
font-size: 13px;
}
.switch-row {
justify-content: center;
}
input,
textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(72, 240, 220, 0.22);
border-radius: 6px;
background: rgba(2, 15, 20, 0.86);
color: #eaffff;
min-height: 34px;
padding: 8px 10px;
outline: none;
}
textarea {
resize: vertical;
line-height: 1.5;
}
.full-field {
margin-top: 12px;
}
.mapping-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.mapping-header,
.mapping-row {
display: grid;
grid-template-columns: 0.8fr 1fr 1.25fr 0.8fr 42px;
gap: 8px;
align-items: center;
}
.mapping-header {
color: #8fb1b8;
font-size: 12px;
padding: 0 2px;
}
.icon-btn {
width: 38px;
padding: 0;
}
.record-block + .record-block {
margin-top: 16px;
}
.record-item {
border-top: 1px solid rgba(72, 240, 220, 0.12);
padding: 10px 0;
}
.record-item strong,
.record-item span {
display: block;
}
.record-item span,
.empty-text {
color: #8fb1b8;
font-size: 13px;
}
.record-item p {
margin: 6px 0 0;
color: #d6eef2;
line-height: 1.5;
}
.record-item.error p {
color: #ffb4b4;
}
@media (max-width: 1180px) {
.kingdee-layout,
.status-grid {
grid-template-columns: 1fr;
}
.kingdee-page {
min-width: 0;
}
}
</style>

View File

@@ -0,0 +1,533 @@
<template>
<div v-if="isVisible" class="login-modal-overlay">
<div class="login-modal">
<div class="login-header">
<h2>账号登录</h2>
</div>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label for="username">账号</label>
<input id="username" v-model="username" type="text" placeholder="请输入账号" required />
</div>
<div class="form-group">
<label for="password">密码</label>
<input id="password" v-model="password" type="password" placeholder="请输入密码" required />
</div>
<div class="form-group">
<label for="captcha">验证码</label>
<div class="captcha-container">
<input id="captcha" v-model="captcha" type="text" placeholder="请输入验证码" maxlength="4" required />
<img :src="captchaImage" alt="验证码" @click="refreshCaptcha" class="captcha-image" />
</div>
</div>
<!--<div class="checkbox-group">
<label class="checkbox-label">
<input v-model="rememberPassword" type="checkbox" />
记住密码
</label>
</div>-->
<div class="checkbox-group">
<label class="checkbox-label">
<input v-model="agreeTerms" type="checkbox" required />
(勾选即代表同意)软件仅用于测试学习场景不得用于非法途径
</label>
</div>
<div class="form-actions">
<button type="submit" class="login-btn" :disabled="!agreeTerms">
登录
</button>
<a href="#" class="forgot-password" @click.prevent="handleForgotPassword">
忘记密码
</a>
</div>
</form>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { SaveCallbackConfig, GetCallbackConfig } from '../../wailsjs/go/main/App.js';
const props = defineProps({
isVisible: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['login-success', 'login-failed']);
const username = ref('');
const password = ref('');
const captcha = ref('');
const rememberPassword = ref(false);
const agreeTerms = ref(false);
const errorMessage = ref('');
const captchaImage = ref('');
// 生成简单的验证码
const generateCaptcha = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 4; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
const captchaText = ref('');
const refreshCaptcha = () => {
captchaText.value = generateCaptcha();
// 创建简单的SVG验证码
const svgCaptcha = `
<svg width="100" height="40" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#f0f0f0"/>
<text x="10" y="25" font-family="Arial" font-size="20" fill="#333"
letter-spacing="8" transform="rotate(-5 10 25)">
${captchaText.value}
</text>
<path d="M0,20 Q50,10 100,30" stroke="#ddd" stroke-width="1" fill="none"/>
<path d="M0,30 Q50,40 100,20" stroke="#ddd" stroke-width="1" fill="none"/>
</svg>
`;
captchaImage.value = 'data:image/svg+xml;base64,' + btoa(svgCaptcha);
};
const handleLogin = async () => {
// 验证码验证
if (captcha.value.toUpperCase() !== captchaText.value.toUpperCase()) {
errorMessage.value = '验证码错误,请重新输入';
refreshCaptcha();
return;
}
// 基础验证
if (!username.value || !password.value) {
errorMessage.value = '请输入账号和密码';
return;
}
try {
errorMessage.value = '';
//const response = await fetch('https://crm.cxier.com/admin-api/system/auth/work-pw-login', {
const response = await fetch('https://crm.yelangjiang.com/admin-api/system/auth/work-pw-login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username.value,
password: password.value
})
});
const result = await response.json();
if (result.code === 0 && result.data) {
// 登录成功
const { userId, accessToken, refreshToken, expiresTime } = result.data;
// 保存登录信息到本地存储
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
localStorage.setItem('userId', userId.toString());
localStorage.setItem('tokenExpires', expiresTime.toString());
// 将accessToken保存到config.json的callbackToken字段
try {
// 先获取当前配置
const currentConfig = await window.go.main.App.GetCallbackConfig();
// 构建正确的CallbackConfig结构
const callbackConfigData = {
// 获取现有的callbackConfig或创建一个空对象
...(currentConfig && typeof currentConfig === 'object' && currentConfig.callbackConfig ? currentConfig.callbackConfig : {}),
// 更新callbackToken字段
callbackToken: accessToken,
// 确保所有必需字段都存在
callbackUrl: currentConfig && currentConfig.callbackConfig && currentConfig.callbackConfig.callbackUrl ? currentConfig.callbackConfig.callbackUrl : '',
httpPort: currentConfig && currentConfig.callbackConfig && currentConfig.callbackConfig.httpPort ? currentConfig.callbackConfig.httpPort : '10001',
enableCallback: currentConfig && currentConfig.callbackConfig && currentConfig.callbackConfig.enableCallback !== undefined ? currentConfig.callbackConfig.enableCallback : false,
enableCloudAuth: currentConfig && currentConfig.callbackConfig && currentConfig.callbackConfig.enableCloudAuth !== undefined ? currentConfig.callbackConfig.enableCloudAuth : false,
fileUploadUrl: currentConfig && currentConfig.callbackConfig && currentConfig.callbackConfig.fileUploadUrl ? currentConfig.callbackConfig.fileUploadUrl : '',
deviceCode: currentConfig && currentConfig.callbackConfig && currentConfig.callbackConfig.deviceCode ? currentConfig.callbackConfig.deviceCode : ''
};
const jsonData = JSON.stringify(callbackConfigData);
await SaveCallbackConfig(jsonData);
} catch (err) {
console.error('保存callbackToken失败:', err);
}
// 记住密码功能
if (rememberPassword.value) {
localStorage.setItem('rememberedUsername', username.value);
localStorage.setItem('rememberedPassword', password.value);
} else {
localStorage.removeItem('rememberedUsername');
localStorage.removeItem('rememberedPassword');
}
// 触发登录成功事件
emit('login-success', {
username: username.value,
userId,
accessToken,
rememberPassword: rememberPassword.value
});
} else {
// 登录失败
errorMessage.value = result.msg || '登录失败,请检查账号密码';
refreshCaptcha();
}
} catch (error) {
console.error('登录请求失败:', error);
errorMessage.value = '网络连接失败,请检查网络后重试';
refreshCaptcha();
}
};
const handleForgotPassword = () => {
errorMessage.value = '请联系软件提供者重置密码';
};
// 加载记住的密码
const loadRememberedCredentials = () => {
const rememberedUsername = localStorage.getItem('rememberedUsername');
const rememberedPassword = localStorage.getItem('rememberedPassword');
if (rememberedUsername && rememberedPassword) {
username.value = rememberedUsername;
password.value = rememberedPassword;
rememberPassword.value = true;
}
};
onMounted(() => {
refreshCaptcha();
loadRememberedCredentials();
});
</script>
<style scoped>
.login-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.login-modal {
background: white;
border-radius: 20px;
padding: 50px 60px;
width: 420px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(10px);
animation: slideUp 0.4s ease-out;
position: relative;
overflow: hidden;
}
.login-modal::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #667eea, #764ba2);
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.login-header h2 {
margin: 0 0 40px 0;
text-align: center;
color: #2c3e50;
font-size: 28px;
font-weight: 700;
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.login-form {
width: 100%;
text-align: left;
margin: 0;
padding: 0;
}
.form-group {
margin-bottom: 20px;
text-align: left;
margin-left: 0;
padding-left: 0;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #333;
font-size: 14px;
font-weight: 500;
text-align: left;
width: 40px;
}
.form-group input[type="text"] {
width: 100%;
padding: 15px 18px;
border: 2px solid #e8ecf0;
border-radius: 12px;
font-size: 16px;
box-sizing: border-box;
text-align: left;
display: block;
margin-left: -3px;
transition: all 0.3s ease;
background: #f8fafb;
}
.form-group input[type="password"] {
width: 100%;
padding: 15px 18px;
border: 2px solid #e8ecf0;
border-radius: 12px;
font-size: 16px;
box-sizing: border-box;
text-align: left;
display: block;
margin: 0;
transition: all 0.3s ease;
background: #f8fafb;
}
.form-group input[type="text"]:focus,
.form-group input[type="password"]:focus {
outline: none;
border-color: #667eea;
background: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
transform: translateY(-1px);
}
.form-group input[type="text"]:hover,
.form-group input[type="password"]:hover {
border-color: #667eea;
background: white;
}
.captcha-group {
text-align: left;
}
.captcha-container {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.captcha-container input {
flex: 1;
min-width: 0;
}
.captcha-image {
height: 48px;
cursor: pointer;
border: 2px solid #e8ecf0;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafb, #e8ecf0);
flex-shrink: 0;
transition: all 0.3s ease;
}
.captcha-image:hover {
border-color: #667eea;
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.checkbox-group {
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
.checkbox-group label {
display: block;
margin-bottom: 8px;
color: #333;
font-size: 14px;
font-weight: 500;
text-align: left;
width: auto;
}
.checkbox-label {
display: flex;
align-items: flex-start;
cursor: pointer;
font-size: 15px;
color: #5a6c7d;
text-align: left;
line-height: 1.5;
transition: color 0.3s ease;
width: 100%;
box-sizing: border-box;
}
.checkbox-label:hover {
color: #667eea;
}
.checkbox-label input[type="checkbox"] {
margin-right: 8px;
width: auto;
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 30px;
}
.login-btn {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border: none;
padding: 16px 40px;
border-radius: 12px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
width: 150px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
position: relative;
overflow: hidden;
}
.login-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.login-btn:hover:not(:disabled)::before {
left: 100%;
}
.login-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
}
.login-btn:hover:not(:disabled) {
background: #40a9ff;
}
.login-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.forgot-password {
color: #1890ff;
text-decoration: none;
font-size: 14px;
}
.forgot-password:hover {
text-decoration: underline;
}
.error-message {
color: #ff4d4f;
text-align: center;
margin-top: 10px;
font-size: 14px;
}
.checkmark {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid #e8ecf0;
border-radius: 4px;
margin-right: 10px;
position: relative;
transition: all 0.3s ease;
background: white;
}
.checkbox-label:hover .checkmark {
border-color: #667eea;
}
.checkbox-label input[type="checkbox"]:checked+.checkmark {
background: linear-gradient(135deg, #667eea, #764ba2);
border-color: #667eea;
}
.checkbox-label input[type="checkbox"]:checked+.checkmark::after {
content: '✓';
position: absolute;
top: 0;
left: 3px;
color: white;
font-size: 12px;
font-weight: bold;
width: 100%;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div class="operation-logs-container">
<div class="logs-header">
<h2>操作记录</h2>
</div>
<div v-if="errorMessage" class="logs-error">
{{ errorMessage }}
</div>
<div class="logs-table-container">
<el-table v-loading="isLoading" :data="sortedLogs" class="logs-table" border>
<el-table-column prop="time" label="时间" width="90" />
<el-table-column prop="source" label="来源" width="110" />
<el-table-column prop="type" label="类型" width="80">
<template #default="scope">
<span
:class="{
'text-info': scope.row.type === 'info',
'text-warning': scope.row.type === 'warning',
'text-danger': scope.row.type === 'error'
}"
>
{{ scope.row.type }}
</span>
</template>
</el-table-column>
<el-table-column prop="content" label="内容" min-width="260" />
<el-table-column prop="duration" label="耗时(ms)" width="110" align="right" />
</el-table>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue';
import { DebugLoadLogEntries, LogFrontend } from '../../wailsjs/go/main/App.js';
const logs = ref([]);
const isLoading = ref(false);
const errorMessage = ref('');
const sortedLogs = computed(() => {
return [...logs.value].sort((a, b) => Number(b.id || 0) - Number(a.id || 0)).slice(0, 10);
});
async function loadOperationLogs() {
isLoading.value = true;
try {
LogFrontend('info', 'loading operation logs...');
const result = await DebugLoadLogEntries();
LogFrontend('info', 'operation logs loaded', result);
if (Array.isArray(result?.entries)) {
logs.value = result.entries;
errorMessage.value = result.success === false
? (result.error || '操作日志文件损坏/读取失败,当前显示内存中的临时记录。')
: '';
return;
}
logs.value = [];
errorMessage.value = result?.error || '操作日志返回格式异常。';
} catch (error) {
logs.value = [];
errorMessage.value = error?.message || '操作日志加载失败。';
LogFrontend('error', 'operation log load threw: ' + errorMessage.value);
} finally {
isLoading.value = false;
}
}
onMounted(() => {
loadOperationLogs();
});
</script>
<style scoped>
.operation-logs-container {
width: 100%;
padding: 20px;
box-sizing: border-box;
color: #18282d;
}
.logs-header {
margin-bottom: 16px;
text-align: left;
}
.logs-header h2 {
margin: 0;
color: #18282d;
font-size: 24px;
font-weight: 600;
}
.logs-error {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid #f3c7c7;
border-radius: 6px;
background: #fff2f2;
color: #9b1c1c;
font-size: 13px;
}
.logs-table-container {
overflow-x: auto;
border: 1px solid #dfe5e8;
border-radius: 8px;
background-color: #fff;
box-shadow: none;
}
.logs-table {
width: 100%;
border-collapse: collapse;
}
.logs-table :deep(.el-table__header-wrapper) th {
background-color: #f8fafb;
font-weight: 600;
color: #415056;
font-size: 14px;
}
.logs-table :deep(.el-table__body-wrapper) td {
font-size: 13px;
padding: 8px 1px;
}
.logs-table :deep(.el-table__body-wrapper) tr:hover {
background-color: #f8fafb;
}
.text-info {
color: #16706d;
font-weight: 500;
}
.text-warning {
color: #e6a23c;
font-weight: 500;
}
.text-danger {
color: #f56c6c;
font-weight: 500;
}
/* Command center skin */
.operation-logs-container {
color: var(--cmd-text);
border-color: var(--cmd-line);
background: var(--cmd-panel);
box-shadow: var(--cmd-shadow);
}
.logs-header h2 {
color: var(--cmd-text);
}
.logs-error {
color: var(--cmd-red);
border-color: rgba(255, 107, 125, 0.34);
background: rgba(255, 107, 125, 0.1);
}
.logs-table-container {
border-color: var(--cmd-line);
background: rgba(8, 20, 26, 0.9);
}
.logs-table :deep(.el-table__header-wrapper) th {
color: var(--cmd-text);
background-color: rgba(13, 31, 39, 0.98);
}
.logs-table :deep(.el-table__body-wrapper) td {
color: var(--cmd-text-soft);
}
.logs-table :deep(.el-table__body-wrapper) tr:hover {
background-color: rgba(72, 240, 220, 0.09);
}
.text-info {
color: var(--cmd-cyan);
}
.text-warning {
color: var(--cmd-amber);
}
.text-danger {
color: var(--cmd-red);
}
</style>

View File

@@ -0,0 +1,548 @@
<template>
<div class="wxwork-account-container">
<div class="account-toolbar-actions">
<button class="btn login-btn" @click="handleStartNewInstance" :disabled="startingInstance">
{{ startingInstance ? '启动中...' : '新增企微实例' }}
</button>
<button class="btn refresh-btn" @click="fetchAccountInfo" :disabled="loading">刷新</button>
</div>
<h2>企微账号信息</h2>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<div v-else-if="accounts.length > 0" class="accounts-list">
<div v-for="account in accounts" :key="account.user_id" class="account-card">
<!-- 头像区域 -->
<div class="avatar-container">
<img :src="account.avatar || '/default-avatar.png'" alt="用户头像" class="avatar">
<span class="status-indicator" :class="account.status === 1 ? 'online' : 'offline'"> </span>
</div>
<!-- 信息区域 -->
<div class="info-container">
<p class="user-id">clientId: {{ account.client_id || account.clientId || '-' }} / PID: {{ account.pid || '-' }}</p>
<p class="user-id">巡检: {{ account.health_message || account.healthMessage || account.runtime_status || '-' }}</p>
<p class="user-id">最后消息: {{ account.last_message_at || account.lastMessageAt || '-' }}</p>
<div class="name-status">
<h3>{{ account.username || account.name || '未知用户' }}</h3>
<span class="status-badge" :class="account.status === 1 ? 'online' : 'offline'">
{{ account.status === 1 ? '在线' : '离线' }}
</span>
</div>
<p class="user-id">用户ID: {{ account.user_id || account.userId || '未知ID' }}</p>
<!-- <p class="username">用户名: {{ account.username || account.name || '未知用户名' }}</p> -->
</div>
<!-- 操作按钮区域 -->
<div class="action-buttons">
<button v-if="account.status === 1" class="btn logout-btn" @click="handleLogout(account.user_id)">
下线
</button>
<button v-else class="btn delete-btn" @click="handleDelete(account.user_id)">
删除
</button>
</div>
</div>
</div>
<div v-else class="empty-state">
<i class="fas fa-user-slash"></i>
<h3>暂无企微账号</h3>
<p v-if="diagnostic" class="diagnostic">{{ diagnostic }}</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { LogFrontend, SendWxWorkData, StartNewWxWorkInstance } from '../../wailsjs/go/main/App.js'
// 移除旧的日志导入
// import { LogInfo, LogError } from '@/../wailsjs/runtime/runtime.js';
// 接收父组件传递的账号信息
const props = defineProps({
accountInfo: {
type: Object,
default: null
}
});
// 浏览器环境下的完整Wails运行时模拟 - 提供所有可能用到的API
if (typeof window.runtime === 'undefined') {
window.runtime = {
// 日志相关方法
LogPrint: console.log,
LogTrace: console.trace,
LogDebug: console.debug,
LogInfo: console.info,
LogWarning: console.warn,
LogError: console.error,
LogFatal: console.error,
// 事件相关方法
EventsOnMultiple: () => console.log('模拟: EventsOnMultiple'),
EventsOn: () => console.log('模拟: EventsOn'),
EventsOff: () => console.log('模拟: EventsOff'),
EventsOnce: () => console.log('模拟: EventsOnce'),
EventsEmit: () => console.log('模拟: EventsEmit'),
// 窗口相关方法
WindowReload: () => console.log('模拟: WindowReload'),
WindowReloadApp: () => console.log('模拟: WindowReloadApp'),
WindowSetAlwaysOnTop: () => console.log('模拟: WindowSetAlwaysOnTop'),
WindowSetSystemDefaultTheme: () => console.log('模拟: WindowSetSystemDefaultTheme'),
WindowSetLightTheme: () => console.log('模拟: WindowSetLightTheme'),
WindowSetDarkTheme: () => console.log('模拟: WindowSetDarkTheme'),
WindowCenter: () => console.log('模拟: WindowCenter'),
WindowSetTitle: () => console.log('模拟: WindowSetTitle'),
WindowFullscreen: () => console.log('模拟: WindowFullscreen'),
WindowUnfullscreen: () => console.log('模拟: WindowUnfullscreen'),
WindowIsFullscreen: () => false,
WindowGetSize: () => ({ width: 800, height: 600 }),
WindowSetSize: () => console.log('模拟: WindowSetSize'),
WindowSetMaxSize: () => console.log('模拟: WindowSetMaxSize'),
WindowSetMinSize: () => console.log('模拟: WindowSetMinSize'),
WindowSetPosition: () => console.log('模拟: WindowSetPosition'),
WindowGetPosition: () => ({ x: 100, y: 100 }),
WindowHide: () => console.log('模拟: WindowHide'),
WindowShow: () => console.log('模拟: WindowShow'),
WindowMaximise: () => console.log('模拟: WindowMaximise'),
WindowToggleMaximise: () => console.log('模拟: WindowToggleMaximise'),
WindowUnmaximise: () => console.log('模拟: WindowUnmaximise'),
WindowIsMaximised: () => false,
WindowMinimise: () => console.log('模拟: WindowMinimise'),
WindowUnminimise: () => console.log('模拟: WindowUnminimise'),
WindowSetBackgroundColour: () => console.log('模拟: WindowSetBackgroundColour'),
WindowIsMinimised: () => false,
WindowIsNormal: () => true,
// 其他功能方法
ScreenGetAll: () => [{ id: 1, width: 1920, height: 1080 }],
BrowserOpenURL: (url) => console.log(`模拟: 打开URL ${url}`),
Environment: () => ({ os: 'browser' }),
Quit: () => console.log('模拟: Quit'),
Hide: () => console.log('模拟: Hide'),
Show: () => console.log('模拟: Show'),
ClipboardGetText: () => '',
ClipboardSetText: (text) => console.log(`模拟: 设置剪贴板文本 ${text}`),
OnFileDrop: () => console.log('模拟: OnFileDrop'),
OnFileDropOff: () => console.log('模拟: OnFileDropOff'),
CanResolveFilePaths: () => false,
ResolveFilePaths: (files) => files
};
}
// 状态变量
const loading = ref(false);
const error = ref('');
const accounts = ref([]);
const diagnostic = ref('');
const startingInstance = ref(false);
// 计算属性,优先使用父组件传入的数据,如果没有则使用本地数据
const displayAccountInfo = computed(() => {
const result = props.accountInfo || localAccountInfo.value || null;
LogFrontend('info', `displayAccountInfo计算结果: ${result ? '有数据' : '无数据'}`);
if (result) {
console.log('displayAccountInfo详情:', result);
}
return result;
});
// 获取企微账号信息
async function fetchAccountInfo() {
LogFrontend('info', '开始获取企微账号信息');
loading.value = true;
error.value = '';
diagnostic.value = '';
try {
LogFrontend('info', '准备调用GetWxWorkAccountList方法');
// 通过主程序加载企微账号列表
if (window.go?.main?.App?.GetWxWorkAccountList) {
const result = await window.go.main.App.GetWxWorkAccountList();
// 处理Wails返回的单个值 - 优化处理逻辑
LogFrontend('info', 'Wails后端返回结果', {
result,
resultType: typeof result,
isArray: Array.isArray(result),
hasSuccess: result && typeof result === 'object' && result.success !== undefined,
hasData: result && typeof result === 'object' && result.data !== undefined
});
// 处理新的统一对象格式
if (result && typeof result === 'object') {
const { success, error: errorMsg, data, diagnostic: diagnosticMsg } = result;
if (success !== false && Array.isArray(data)) {
accounts.value = data;
diagnostic.value = diagnosticMsg || '';
LogFrontend('info', `成功加载 ${data.length} 个企微账号(对象格式)`);
} else {
LogFrontend('warn', errorMsg || '获取数据失败,使用空数组');
accounts.value = [];
diagnostic.value = diagnosticMsg || errorMsg || '';
}
} else if (Array.isArray(result)) {
// 兼容旧格式:直接数组
accounts.value = result;
LogFrontend('info', `成功加载 ${result.length} 个企微账号(数组格式)`);
} else {
LogFrontend('warn', '返回数据格式未知,使用空数组', result);
accounts.value = [];
diagnostic.value = '';
}
} else {
LogFrontend('error', 'Wails方法不可用');
error.value = 'Wails方法不可用';
accounts.value = [];
diagnostic.value = '';
}
} catch (err) {
const errorMessage = err?.message || '加载企微账号信息失败';
LogFrontend('error', `加载过程中出现异常: ${errorMessage}`);
error.value = errorMessage;
accounts.value = [];
diagnostic.value = '';
} finally {
loading.value = false;
}
}
// 处理退出
async function handleLogout(userId) {
LogFrontend('info', `尝试退出企微账号: ${userId}`);
try {
// 找到对应的账号并更新状态
const account = accounts.value.find(acc => acc.user_id === userId);
if (account) {
account.status = 0;
}
LogFrontend('info', `账号 ${userId} 已设置为离线状态`);
const requestData = {
"type": 11112,
"data": {}
};
const jsonData = JSON.stringify(requestData);
// 调用后端SendWxWorkData函数客户端ID暂时使用0
const result = await SendWxWorkData(userId, jsonData);
} catch (err) {
const errorMessage = err && err.message ? err.message : '退出过程中出现异常';
LogFrontend('error', errorMessage);
}
}
// 处理删除
async function handleDelete(userId) {
if (confirm('确定要删除此企微账号吗?')) {
LogFrontend('info', `删除企微账号: ${userId}`);
try {
// 从列表中删除账号
accounts.value = accounts.value.filter(acc => acc.user_id !== userId);
LogFrontend('info', `账号 ${userId} 已从列表中删除`);
// 调用后端方法删除client_status.json中的对应数据
if (window.go?.main?.App?.DeleteWxWorkAccount) {
LogFrontend('info', `调用后端删除client_status.json中的账号: ${userId}`);
const result = await window.go.main.App.DeleteWxWorkAccount(userId);
LogFrontend('info', `后端删除结果: ${result}`);
} else {
LogFrontend('warn', '后端删除方法不可用,仅删除了前端列表中的账号');
}
} catch (err) {
const errorMessage = err && err.message ? err.message : '删除过程中出现异常';
LogFrontend('error', errorMessage);
}
}
}
// 生命周期钩子
async function handleStartNewInstance() {
startingInstance.value = true;
error.value = '';
try {
const result = await StartNewWxWorkInstance();
if (!result?.success) {
error.value = result?.message || '新增企微实例失败';
return;
}
diagnostic.value = `${result.message || '已发送新增企微实例请求'},请在新窗口扫码登录。`;
await fetchAccountInfo();
} catch (err) {
error.value = err?.message || String(err || '新增企微实例失败');
} finally {
startingInstance.value = false;
}
}
onMounted(() => {
fetchAccountInfo();
});
</script>
<style scoped>
.wxwork-account-container {
padding: 20px;
color: #18282d;
}
.account-toolbar-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin: 0 0 16px;
}
.loading {
text-align: center;
padding: 20px;
color: #637379;
}
.diagnostic {
margin-top: 10px;
color: #667085;
font-size: 14px;
line-height: 1.6;
}
.error-message {
color: #9b1c1c;
padding: 10px 12px;
background-color: #fdecec;
border-radius: 6px;
}
.accounts-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.account-card {
display: flex;
align-items: center;
background-color: #fff;
border-radius: 8px;
border: 1px solid #dfe5e8;
box-shadow: none;
padding: 16px 18px;
width: 100%;
box-sizing: border-box;
}
.avatar-container {
position: relative;
margin-right: 20px;
flex-shrink: 0;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #eef2f4;
}
.status-indicator {
position: absolute;
bottom: 3px;
right: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid white;
}
.status-indicator.online {
background-color: #16706d;
}
.status-indicator.offline {
background-color: #95a5a6;
}
.info-container {
flex: 1;
margin-right: 20px;
}
.name-status {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 5px;
}
h3 {
font-size: 18px;
margin: 0;
color: #18282d;
}
.status-badge {
padding: 3px 7px;
border-radius: 999px;
font-size: 11px;
font-weight: bold;
color: white;
}
.status-badge.online {
background-color: #16706d;
}
.status-badge.offline {
background-color: #95a5a6;
}
.user-id {
color: #637379;
margin: 0;
font-size: 14px;
}
.action-buttons {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn {
min-height: 34px;
padding: 0 14px;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
transition: background-color 0.2s, border-color 0.2s;
}
.login-btn {
background-color: #16706d;
color: white;
}
.login-btn:hover {
background-color: #115c5a;
}
.logout-btn {
background-color: #fff;
color: #9b1c1c;
border-color: #f0b8b8;
}
.logout-btn:hover {
background-color: #fdecec;
}
.delete-btn {
background-color: #fff;
color: #8a5a00;
border-color: #ead19a;
}
.delete-btn:hover {
background-color: #fff8e6;
}
.empty-state {
text-align: center;
padding: 40px 0;
color: #637379;
}
/* Command center skin */
.wxwork-account-container {
color: var(--cmd-text);
}
.loading,
.diagnostic,
.user-id,
.empty-state {
color: var(--cmd-text-soft);
}
.account-card {
border-color: var(--cmd-line);
background:
linear-gradient(135deg, rgba(18, 42, 52, 0.92), rgba(8, 20, 26, 0.96)),
var(--cmd-panel);
box-shadow: var(--cmd-shadow);
}
.account-card:hover {
border-color: rgba(72, 240, 220, 0.42);
box-shadow: var(--cmd-shadow), 0 0 24px rgba(72, 240, 220, 0.12);
}
.avatar {
border-color: rgba(72, 240, 220, 0.32);
box-shadow: 0 0 22px rgba(72, 240, 220, 0.12);
}
.status-indicator {
border-color: #071014;
}
.status-indicator.online,
.status-badge.online {
background: var(--cmd-green);
}
.status-indicator.offline,
.status-badge.offline {
background: #6f858e;
}
h3 {
color: var(--cmd-text);
}
.status-badge {
color: #031316;
}
.error-message {
color: var(--cmd-red);
border: 1px solid rgba(255, 107, 125, 0.34);
background: rgba(255, 107, 125, 0.1);
}
.login-btn {
color: #031316;
border-color: rgba(72, 240, 220, 0.82);
background: linear-gradient(135deg, var(--cmd-cyan), var(--cmd-blue));
box-shadow: 0 0 20px rgba(72, 240, 220, 0.16);
}
.login-btn:hover {
background: linear-gradient(135deg, #7ff7e9, #8fc2ff);
}
.logout-btn {
color: #ffd8dd;
border-color: rgba(255, 107, 125, 0.48);
background: rgba(255, 107, 125, 0.1);
}
.logout-btn:hover {
background: rgba(255, 107, 125, 0.16);
}
.delete-btn {
color: var(--cmd-amber);
border-color: rgba(255, 209, 102, 0.42);
background: rgba(255, 209, 102, 0.1);
}
.delete-btn:hover {
background: rgba(255, 209, 102, 0.16);
}
</style>