feat(app): 诚发五金首页与 8 大模块工作台,工单助手全流程接入
- 新增 chenfa 公司配置与专属首页(8 大模块 + 工单助手 KPI 与快捷入口)
- 工作台按计划排程/生产管控/设备管理/采购管理/仓储物流/营销管理/人力资源/工单助手固定分组
- 工单助手内置入口:上报、受理、分析、问题库、退货申请(按权限展示)
- 新增工单分析页:状态分布、问题类型、处理人工作量、签到/升级/满意度
- 工单详情支持现场签到(定位)、转派、升级、处理记录与进度时间线
- 工单上报支持扫码带出销售订单,断网时本地暂存并在联网后自动补传
Co-Authored-By: Claude Code <noreply@anthropic.com>
| | |
| | | contactName?: string; saleOrderId?: number; saleOrderNo?: string; contractId?: number; issueType?: number; |
| | | issueCategory?: string; issueDescription?: string; severityLevel?: number; status?: number; ownerUserId?: number; |
| | | ownerUserName?: string; handlerUserId?: number; handlerUserName?: string; processInstanceId?: string; |
| | | returnId?: number; repairId?: number; resolvedTime?: string; closedTime?: string; visitStatus?: number; |
| | | returnId?: number; repairId?: number; signInTime?: string; signInAddress?: string; resolvedTime?: string; closedTime?: string; visitStatus?: number; |
| | | lastVisitTime?: string; lastVisitSatisfied?: boolean; lastVisitRecurred?: boolean; problemId?: number; |
| | | problemNo?: string; remark?: string; creator?: string; createTime?: string; updater?: string; updateTime?: string; |
| | | blobIds?: number[]; attachmentList?: AftersalesAttachment[]; items?: TicketItem[]; |
| | |
| | | rootCause?: string; solution?: string; status?: number; ownerUserId?: number; ownerUserName?: string; |
| | | publishTime?: string; remark?: string; creator?: string; createTime?: string; updater?: string; updateTime?: string; |
| | | blobIds?: number[]; attachmentList?: AftersalesAttachment[]; |
| | | } |
| | | export interface AftersalesTicketLog { |
| | | id: number; ticketId: number; actionType: number; actionName?: string; content?: string; |
| | | operatorUserId?: number; operatorUserName?: string; longitude?: number; latitude?: number; |
| | | address?: string; createTime?: string; |
| | | } |
| | | export interface TicketStatisticsStatusCount { status: number; statusName: string; count: number } |
| | | export interface TicketStatisticsIssueTypeCount { issueType: number; issueTypeName: string; count: number } |
| | | export interface TicketStatisticsHandlerWorkload { handlerUserId: number; handlerUserName: string; processingCount: number; totalCount: number } |
| | | export interface AftersalesTicketStatistics { |
| | | totalCount: number; processingCount: number; waitingReturnCount: number; waitingRefundCount: number; |
| | | completedCount: number; closedCount: number; canceledCount: number; todayCreateCount: number; |
| | | todayResolveCount: number; signInCount: number; escalatedCount: number; |
| | | avgResolveHours: number; satisfactionRate: number; |
| | | statusCounts: TicketStatisticsStatusCount[]; |
| | | issueTypeCounts: TicketStatisticsIssueTypeCount[]; |
| | | handlerWorkloads: TicketStatisticsHandlerWorkload[]; |
| | | } |
| | | export interface AftersalesVisit { |
| | | id?: number; ticketId?: number; visitType?: number; visitorUserId?: number; visitorUserName?: string; |
| | |
| | | export function resolveTicket(token: string, id: number) { return request<boolean>(`/aftersales/ticket/resolve${query({ id })}`, { method: "PUT", header: header(token) }); } |
| | | export function closeTicket(token: string, id: number) { return request<boolean>(`/aftersales/ticket/close${query({ id })}`, { method: "PUT", header: header(token) }); } |
| | | export function cancelTicket(token: string, id: number) { return request<boolean>(`/aftersales/ticket/cancel${query({ id })}`, { method: "PUT", header: header(token) }); } |
| | | export function getTicketLogList(token: string, ticketId: number) { return request<AftersalesTicketLog[]>(`/aftersales/ticket/log-list${query({ ticketId })}`, { header: header(token) }); } |
| | | export function signInTicket(token: string, data: { id: number; longitude?: number; latitude?: number; address?: string; content?: string }) { |
| | | return request<boolean>("/aftersales/ticket/sign-in", { method: "POST", data: data as unknown as Record<string, unknown>, header: header(token) }); |
| | | } |
| | | export function transferTicket(token: string, data: { id: number; handlerUserId: number; reason?: string }) { |
| | | return request<boolean>("/aftersales/ticket/transfer", { method: "POST", data: data as unknown as Record<string, unknown>, header: header(token) }); |
| | | } |
| | | export function escalateTicket(token: string, data: { id: number; severityLevel?: number; reason?: string }) { |
| | | return request<boolean>("/aftersales/ticket/escalate", { method: "POST", data: data as unknown as Record<string, unknown>, header: header(token) }); |
| | | } |
| | | export function addHandleRecord(token: string, data: { id: number; content: string }) { |
| | | return request<boolean>("/aftersales/ticket/handle", { method: "POST", data: data as unknown as Record<string, unknown>, header: header(token) }); |
| | | } |
| | | export function getTicketStatistics(token: string, sceneType?: number | string) { |
| | | return request<AftersalesTicketStatistics>(`/aftersales/ticket/statistics${query({ sceneType })}`, { header: header(token) }); |
| | | } |
| | | |
| | | export function getReturnPage(token: string, params: { pageNo: number; pageSize: number; no?: string; ticketId?: number; customerId?: number; status?: number }) { return request<PageResult<AftersalesReturn>>(`/aftersales/return/page${query(params)}`, { header: header(token) }); } |
| | | export function getReturn(token: string, id: number) { return request<AftersalesReturn>(`/aftersales/return/get${query({ id })}`, { header: header(token) }); } |
| | |
| | | export type HomeVariant = "classic" | "changhui-power" | "kangyi-intelligent" | "lanyu-paper" | "north-tech" | "xingao-power" | "zhongyi-assembly"; |
| | | export type HomeVariant = "classic" | "changhui-power" | "kangyi-intelligent" | "lanyu-paper" | "north-tech" | "xingao-power" | "zhongyi-assembly" | "chenfa-tools"; |
| | | |
| | | /** æå
ç®æ ï¼æ¹ `ACTIVE_COMPANY` ä¸è¡ï¼é¦é¡µçæ¬ä¸å端å°å忶忢ã */ |
| | | export type CompanyCode = "main" | "trt" | "changhui" | "kangyi" | "lanyu" | "beifang" | "xingao" | "zhongyi"; |
| | | export type CompanyCode = "main" | "trt" | "changhui" | "kangyi" | "lanyu" | "beifang" | "xingao" | "zhongyi" | "chenfa"; |
| | | |
| | | export interface AppCompanyConfig { |
| | | /** å
¬å¸æ è¯ï¼ä¸ CompanyCode ä¸è´ï¼ä¾¿äºææ¥å¿ / ç¼åå½åç©ºé´ */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * å
«å¥ç¯å¢å¯¹ç
§è¡¨ã |
| | | * ä¹å¥ç¯å¢å¯¹ç
§è¡¨ã |
| | | * - apiBaseUrl ç»ä¸ä»¥ `/admin-api` ç»å°¾ï¼yudao åå°æ¥å£åç¼ï¼ã |
| | | * - webBaseUrlï¼åªæä¸»åæ¯ï¼MOM æºè½æºé ï¼æ¯æ¢æé
ç½®ï¼9501ï¼ï¼å
¶ä½æä¸»åæ¯ï¼MOM æºè½æºé ï¼âæ¥å£ç«¯å£ +1âçé¨ç½²è§å¾ |
| | | * æ¨å¯¼ï¼9500 â 9501ï¼ã9017 / 9027 / 9025 宿µè½æå¼åå°ï¼1.95.78.124:9007 ç®åè¿ä¸ä¸ |
| | |
| | | apiBaseUrl: "http://36.213.150.237:9024/admin-api", |
| | | webBaseUrl: "http://36.213.150.237:9025", |
| | | }, |
| | | /** 滦åè¯åäºéï¼å
è¿æ¬å° CFWJ åºï¼48081ï¼ï¼é¨ç½²æ¶åªæ¹ apiBaseUrl ä¸è¡ */ |
| | | chenfa: { |
| | | key: "chenfa", |
| | | name: "滦åè¯åäºé", |
| | | homeVariant: "chenfa-tools", |
| | | apiBaseUrl: "http://127.0.0.1:48081/admin-api", |
| | | webBaseUrl: "http://127.0.0.1:5669", |
| | | }, |
| | | }; |
| | | |
| | | /** â
æå
å¼å
³ï¼åªæ¹è¿ä¸è¡ */ |
| | | export const ACTIVE_COMPANY: CompanyCode = "changhui"; |
| | | export const ACTIVE_COMPANY: CompanyCode = "chenfa"; |
| | | |
| | | export const APP_COMPANY: AppCompanyConfig = COMPANY_PROFILES[ACTIVE_COMPANY]; |
| | |
| | | "style": { "navigationBarTitleText": "å®åå·¥å详æ
" } |
| | | }, |
| | | { |
| | | "path": "ticket/statistics", |
| | | "style": { "navigationBarTitleText": "å·¥ååæ", "enablePullDownRefresh": true } |
| | | }, |
| | | { |
| | | "path": "return/index", |
| | | "style": { "navigationBarTitleText": "éè´§ç³è¯·", "enablePullDownRefresh": true } |
| | | }, |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { createTicket, type AftersalesTicket } from "../../api/aftersales"; |
| | | |
| | | /** |
| | | * å·¥å䏿¥ç¦»çº¿éåï¼æ¹æ¡ 8ãæ¯æç¦»çº¿æä½ï¼æ°æ®èªå¨åæ¥ãï¼ã |
| | | * App 端æ ç½ç»æ¶å
æå·¥åæå卿¬å°ï¼åå°æç½ç¯å¢ååæä¸æ¥æ¶é´é¡ºåºè¡¥ä¼ ã |
| | | */ |
| | | const QUEUE_KEY = "aftersales_ticket_offline_queue"; |
| | | |
| | | export interface QueuedTicket { |
| | | localId: string; |
| | | createdAt: string; |
| | | data: AftersalesTicket; |
| | | } |
| | | |
| | | export function isNetworkError(cause: unknown) { |
| | | return cause instanceof Error && /ç½ç»è¿æ¥å¤±è´¥|request:fail|Failed to fetch/i.test(cause.message); |
| | | } |
| | | |
| | | export function readQueue(): QueuedTicket[] { |
| | | try { |
| | | return (uni.getStorageSync(QUEUE_KEY) as QueuedTicket[]) || []; |
| | | } catch { |
| | | return []; |
| | | } |
| | | } |
| | | |
| | | function writeQueue(queue: QueuedTicket[]) { |
| | | uni.setStorageSync(QUEUE_KEY, queue); |
| | | } |
| | | |
| | | export function enqueueTicket(data: AftersalesTicket) { |
| | | const queue = readQueue(); |
| | | const created = new Date(); |
| | | queue.push({ |
| | | localId: `${created.getTime()}-${Math.floor(Math.random() * 1000)}`, |
| | | createdAt: created.toISOString(), |
| | | data: JSON.parse(JSON.stringify(data)), |
| | | }); |
| | | writeQueue(queue); |
| | | } |
| | | |
| | | export function queueCount() { |
| | | return readQueue().length; |
| | | } |
| | | |
| | | /** æç½æ¶è¡¥ä¼ 离线工åï¼è¿åæåæ¡æ°ä¸å©ä½æ¡æ° */ |
| | | export async function flushQueue(token: string) { |
| | | const queue = readQueue(); |
| | | if (!queue.length) return { synced: 0, remaining: 0 }; |
| | | const pending: QueuedTicket[] = []; |
| | | let synced = 0; |
| | | let offline = false; |
| | | for (const item of queue) { |
| | | if (offline) { |
| | | pending.push(item); |
| | | continue; |
| | | } |
| | | try { |
| | | await createTicket(token, item.data); |
| | | synced += 1; |
| | | } catch (cause) { |
| | | if (isNetworkError(cause)) { |
| | | // ä»ç¶æç½ï¼ä¿çæ¬æ¡ååç»ï¼ç䏿¬¡èç½åè¡¥ä¼ |
| | | offline = true; |
| | | pending.push(item); |
| | | } else { |
| | | // ä¸å¡æ ¡éªå¤±è´¥ï¼å¦å®¢æ·å·²åç¨ï¼ä¸è½æ ééè¯ï¼è·³è¿å¹¶å¨æ§å¶å°çç |
| | | console.warn("离线工åè¡¥ä¼ å¤±è´¥ï¼å·²è·³è¿ï¼", cause); |
| | | } |
| | | } |
| | | } |
| | | writeQueue(pending); |
| | | return { synced, remaining: pending.length }; |
| | | } |
| | |
| | | <script setup lang="ts"> |
| | | import { ref } from "vue"; |
| | | import { onLoad } from "@dcloudio/uni-app"; |
| | | import { assignTicket, cancelTicket, classifyTicket, closeTicket, createVisit, getTicket, getVisitListByTicketId, resolveTicket, type AftersalesTicket, type AftersalesVisit, type CreateAftersalesVisit } from "../../../api/aftersales"; |
| | | import { |
| | | addHandleRecord, assignTicket, cancelTicket, classifyTicket, closeTicket, createVisit, escalateTicket, |
| | | getTicket, getTicketLogList, getVisitListByTicketId, resolveTicket, signInTicket, transferTicket, |
| | | type AftersalesTicket, type AftersalesTicketLog, type AftersalesVisit, type CreateAftersalesVisit, |
| | | } from "../../../api/aftersales"; |
| | | import { getHrmUserList, type UserOption } from "../../../api/hrm"; |
| | | import { useAuth } from "../../../stores/auth"; |
| | | import PickerSheet from "../../mes/process-design/components/PickerSheet.vue"; |
| | | import AftersalesAttachmentField from "../AftersalesAttachmentField.vue"; |
| | | import { ISSUE_TYPES, SEVERITIES, TICKET_STATUS, VISIT_TYPES, confirm, dateTime, labelOf, toast } from "../shared"; |
| | | |
| | | const { state } = useAuth(); |
| | | const id = ref(0); const ticket = ref<AftersalesTicket>(); const visits = ref<AftersalesVisit[]>([]); const users = ref<UserOption[]>([]); |
| | | const { hasPermission, state } = useAuth(); |
| | | const id = ref(0); const ticket = ref<AftersalesTicket>(); const visits = ref<AftersalesVisit[]>([]); const users = ref<UserOption[]>([]); const logs = ref<AftersalesTicketLog[]>([]); |
| | | const assignVisible = ref(false); const classifyVisible = ref(false); const issueVisible = ref(false); const severityVisible = ref(false); const visitVisible = ref(false); const visitTypeVisible = ref(false); |
| | | const transferVisible = ref(false); const escalateVisible = ref(false); const handleVisible = ref(false); const signInVisible = ref(false); |
| | | const handlerSheetVisible = ref(false); const escalateSeverityVisible = ref(false); |
| | | const handlerId = ref<number>(); const issueType = ref<number>(); const issueCategory = ref(""); const severity = ref<number>(); |
| | | const transferForm = ref<{ handlerUserId?: number; reason: string }>({ reason: "" }); |
| | | const escalateForm = ref<{ severityLevel?: number; reason: string }>({ reason: "" }); |
| | | const handleContent = ref(""); |
| | | const signForm = ref<{ longitude?: number; latitude?: number; address: string; content: string }>({ address: "", content: "" }); |
| | | const visitForm = ref<CreateAftersalesVisit>({ ticketId: 0, visitType: 0, content: "", satisfied: true, recurred: false, remark: "" }); |
| | | const canHandle = () => hasPermission("aftersales:ticket:update"); |
| | | |
| | | async function loadTicket() { |
| | | if (!state.accessToken || !id.value) return; |
| | |
| | | handlerId.value = ticket.value.handlerUserId; issueType.value = ticket.value.issueType; issueCategory.value = ticket.value.issueCategory || ""; severity.value = ticket.value.severityLevel; |
| | | } |
| | | async function loadVisits() { if (state.accessToken && id.value) visits.value = await getVisitListByTicketId(state.accessToken, id.value); } |
| | | async function loadLogs() { if (state.accessToken && id.value) logs.value = await getTicketLogList(state.accessToken, id.value); } |
| | | async function assign(keys: Array<number | string>) { |
| | | handlerId.value = Number(keys[0]); if (!state.accessToken || !handlerId.value) return; |
| | | try { await assignTicket(state.accessToken, id.value, handlerId.value); toast("åé
æå"); await loadTicket(); } |
| | | try { await assignTicket(state.accessToken, id.value, handlerId.value); toast("åé
æå"); await refresh(); } |
| | | catch (cause) { toast(cause instanceof Error ? cause.message : "åé
失败"); } |
| | | } |
| | | async function classify() { |
| | | if (!state.accessToken || !issueType.value) return toast("è¯·éæ©é®é¢ç±»å"); |
| | | try { await classifyTicket(state.accessToken, { id: id.value, issueType: issueType.value, issueCategory: issueCategory.value || undefined, severityLevel: severity.value }); toast("é®é¢å¤å®æå"); classifyVisible.value = false; await loadTicket(); } |
| | | try { await classifyTicket(state.accessToken, { id: id.value, issueType: issueType.value, issueCategory: issueCategory.value || undefined, severityLevel: severity.value }); toast("é®é¢å¤å®æå"); classifyVisible.value = false; await refresh(); } |
| | | catch (cause) { toast(cause instanceof Error ? cause.message : "å¤å®å¤±è´¥"); } |
| | | } |
| | | async function flow(type: "resolve" | "close" | "cancel") { |
| | |
| | | if (!await confirm(content)) return; |
| | | try { |
| | | if (type === "resolve") await resolveTicket(state.accessToken, id.value); else if (type === "close") await closeTicket(state.accessToken, id.value); else await cancelTicket(state.accessToken, id.value); |
| | | toast(`${label}æå`); await Promise.all([loadTicket(), loadVisits()]); |
| | | toast(`${label}æå`); await refresh(); |
| | | } catch (cause) { toast(cause instanceof Error ? cause.message : `${label}失败`); } |
| | | } |
| | | function openVisit() { visitForm.value = { ticketId: id.value, visitType: 0, content: "", satisfied: true, recurred: false, remark: "" }; visitVisible.value = true; } |
| | | async function submitVisit() { |
| | | if (!state.accessToken) return; |
| | | try { await createVisit(state.accessToken, { ...visitForm.value, ticketId: id.value }); toast("å访ç»è®°æå"); visitVisible.value = false; await Promise.all([loadTicket(), loadVisits()]); } |
| | | try { await createVisit(state.accessToken, { ...visitForm.value, ticketId: id.value }); toast("å访ç»è®°æå"); visitVisible.value = false; await refresh(); } |
| | | catch (cause) { toast(cause instanceof Error ? cause.message : "å访ç»è®°å¤±è´¥"); } |
| | | } |
| | | function createReturn() { uni.navigateTo({ url: `/pages/aftersales/return/form?ticketId=${id.value}` }); } |
| | | |
| | | /** ç°åºç¾å°ï¼å
åå½åå®ä½ï¼åä¸å°ä¹å
许æå¡«å°ååç¾å°ï¼å¼±ç½/æªææå®ä½åºæ¯ï¼ */ |
| | | function locate(): Promise<{ longitude?: number; latitude?: number }> { |
| | | return new Promise(resolve => { |
| | | uni.getLocation({ |
| | | type: "gcj02", |
| | | success: result => resolve({ longitude: Number(result.longitude.toFixed(6)), latitude: Number(result.latitude.toFixed(6)) }), |
| | | fail: () => resolve({}), |
| | | }); |
| | | }); |
| | | } |
| | | async function openSignIn() { |
| | | signForm.value = { address: ticket.value?.signInAddress || "", content: "" }; |
| | | signInVisible.value = true; |
| | | const position = await locate(); |
| | | signForm.value = { ...signForm.value, ...position }; |
| | | if (!position.longitude) toast("æªè·åå°å®ä½ï¼å¯æå¨å¡«åç°åºå°ååç¾å°"); |
| | | } |
| | | async function submitSignIn() { |
| | | if (!state.accessToken) return; |
| | | if (!signForm.value.address.trim()) return toast("请填åç°åºå°åï¼ä¾¿äºè¿½æº¯æå¡ä½ç½®"); |
| | | try { |
| | | await signInTicket(state.accessToken, { id: id.value, ...signForm.value, address: signForm.value.address.trim(), content: signForm.value.content.trim() || undefined }); |
| | | toast("ç°åºç¾å°æå"); signInVisible.value = false; await refresh(); |
| | | } catch (cause) { toast(cause instanceof Error ? cause.message : "ç¾å°å¤±è´¥"); } |
| | | } |
| | | function openTransfer() { transferForm.value = { handlerUserId: ticket.value?.handlerUserId, reason: "" }; transferVisible.value = true; } |
| | | async function submitTransfer() { |
| | | if (!state.accessToken) return; |
| | | if (!transferForm.value.handlerUserId) return toast("è¯·éæ©æ°çå¤ç人"); |
| | | if (!transferForm.value.reason.trim()) return toast("请填å转派åå "); |
| | | try { |
| | | await transferTicket(state.accessToken, { id: id.value, handlerUserId: transferForm.value.handlerUserId, reason: transferForm.value.reason.trim() }); |
| | | toast("转派æå"); transferVisible.value = false; await refresh(); |
| | | } catch (cause) { toast(cause instanceof Error ? cause.message : "转派失败"); } |
| | | } |
| | | function openEscalate() { escalateForm.value = { severityLevel: Math.min(3, (ticket.value?.severityLevel ?? 0) + 1), reason: "" }; escalateVisible.value = true; } |
| | | async function submitEscalate() { |
| | | if (!state.accessToken) return; |
| | | if (!escalateForm.value.reason.trim()) return toast("请填åå级åå "); |
| | | try { |
| | | await escalateTicket(state.accessToken, { id: id.value, severityLevel: escalateForm.value.severityLevel, reason: escalateForm.value.reason.trim() }); |
| | | toast("å级æå"); escalateVisible.value = false; await refresh(); |
| | | } catch (cause) { toast(cause instanceof Error ? cause.message : "å级失败"); } |
| | | } |
| | | function openHandle() { handleContent.value = ""; handleVisible.value = true; } |
| | | async function submitHandle() { |
| | | if (!state.accessToken) return; |
| | | if (!handleContent.value.trim()) return toast("请填åå¤çè®°å½å
容"); |
| | | try { |
| | | await addHandleRecord(state.accessToken, { id: id.value, content: handleContent.value.trim() }); |
| | | toast("å¤çè®°å½å·²ä¿å"); handleVisible.value = false; await refresh(); |
| | | } catch (cause) { toast(cause instanceof Error ? cause.message : "ä¿å失败"); } |
| | | } |
| | | async function refresh() { await Promise.all([loadTicket(), loadVisits(), loadLogs()]); } |
| | | onLoad(async query => { |
| | | id.value = Number(query?.id || 0); |
| | | try { if (state.accessToken) users.value = await getHrmUserList(state.accessToken); await Promise.all([loadTicket(), loadVisits()]); } |
| | | try { if (state.accessToken) users.value = await getHrmUserList(state.accessToken); await refresh(); } |
| | | catch (cause) { toast(cause instanceof Error ? cause.message : "å 载失败"); } |
| | | }); |
| | | </script> |
| | |
| | | <view class="detail-row"><text>客æ·</text><text>{{ ticket.customerName || "--" }}</text></view><view class="detail-row"><text>è系人</text><text>{{ ticket.contactName || "--" }}</text></view><view class="detail-row"><text>éå®è®¢åå·</text><text>{{ ticket.saleOrderNo || "--" }}</text></view> |
| | | <view class="detail-row"><text>é®é¢ç±»å</text><text>{{ labelOf(ISSUE_TYPES, ticket.issueType) }}</text></view><view class="detail-row"><text>é®é¢åç±»</text><text>{{ ticket.issueCategory || "--" }}</text></view><view class="detail-row"><text>严éç¨åº¦</text><text>{{ labelOf(SEVERITIES, ticket.severityLevel) }}</text></view> |
| | | <view class="detail-row"><text>é®é¢æè¿°</text><text>{{ ticket.issueDescription || "--" }}</text></view><view class="detail-row"><text>è´è´£äºº</text><text>{{ ticket.ownerUserName || "--" }}</text></view><view class="detail-row"><text>å¤ç人</text><text>{{ ticket.handlerUserName || "--" }}</text></view> |
| | | <view class="detail-row"><text>ç°åºç¾å°</text><text>{{ ticket.signInTime ? dateTime(ticket.signInTime) : "æªç¾å°" }}</text></view><view class="detail-row"><text>ç¾å°å°å</text><text>{{ ticket.signInAddress || "--" }}</text></view> |
| | | <view class="detail-row"><text>åè®¿ç¶æ</text><text>{{ ticket.visitStatus === 1 ? "å·²å访éè¿" : "å¾
å访" }}</text></view><view class="detail-row"><text>é®é¢æ²æ·</text><text>{{ ticket.problemNo || "--" }}</text></view><view class="detail-row"><text>å®ç»æ¶é´</text><text>{{ dateTime(ticket.resolvedTime) }}</text></view><view class="detail-row"><text>å
³éæ¶é´</text><text>{{ dateTime(ticket.closedTime) }}</text></view><view class="detail-row"><text>夿³¨</text><text>{{ ticket.remark || "--" }}</text></view> |
| | | <AftersalesAttachmentField :model-value="ticket.blobIds" :files="ticket.attachmentList" readonly /> |
| | | </view> |
| | | |
| | | <text class="section-title">å·¥åæç»</text> |
| | | <view class="detail-card"><view v-for="(item, index) in ticket.items" :key="item.id || index" class="item"><view class="item-head"><text>{{ item.itemName || "--" }}</text><text>{{ item.itemCode || "--" }}</text></view><text class="item-sub">æ¹æ¬¡ {{ item.batchCode || "--" }} · éå® {{ item.saleQuantity ?? 0 }} · éè´§ {{ item.returnQuantity ?? 0 }}</text><view class="checks"><text>ç»´ä¿®ï¼{{ item.needRepair ? "æ¯" : "å¦" }}</text><text>éè´§ï¼{{ item.needReturn ? "æ¯" : "å¦" }}</text></view></view><view v-if="!ticket.items?.length" class="state">ææ æç»</view></view> |
| | | |
| | | <text class="section-title">å¤çè¿åº¦</text> |
| | | <view class="detail-card"> |
| | | <view v-for="log in logs" :key="log.id" class="log-item"> |
| | | <view class="log-dot" /> |
| | | <view class="log-body"> |
| | | <view class="item-head"><text>{{ log.actionName || "æä½" }}</text><text class="item-sub">{{ dateTime(log.createTime) }}</text></view> |
| | | <text class="item-sub">æä½äººï¼{{ log.operatorUserName || "--" }}</text> |
| | | <text v-if="log.content" class="log-content">{{ log.content }}</text> |
| | | <text v-if="log.address" class="item-sub">ä½ç½®ï¼{{ log.address }}<text v-if="log.longitude">ï¼{{ log.longitude }}, {{ log.latitude }}ï¼</text></text> |
| | | </view> |
| | | </view> |
| | | <view v-if="!logs.length" class="state">ææ å¤çè¿åº¦è®°å½</view> |
| | | </view> |
| | | |
| | | <text class="section-title">å访记å½</text> |
| | | <view class="detail-card"> |
| | |
| | | </view> |
| | | |
| | | <view class="toolbar"> |
| | | <button v-if="ticket.status === 0 || ticket.status === 10" @click="assignVisible = true">åé
å¤ç人</button><button v-if="ticket.status === 10" @click="classifyVisible = true">é®é¢å¤å®</button><button v-if="ticket.status === 10 && ticket.issueType === 3" @click="createReturn">å建éè´§ç³è¯·</button><button v-if="[10, 30, 40].includes(ticket.status ?? -1)" @click="flow('resolve')">å®ç»</button><button v-if="ticket.status === 50 && ticket.visitStatus === 1" @click="flow('close')">彿¡£</button><button v-if="[0, 10].includes(ticket.status ?? -1)" class="danger" @click="flow('cancel')">åæ¶å·¥å</button> |
| | | <button v-if="ticket.status === 0 || ticket.status === 10" @click="assignVisible = true">åé
å¤ç人</button><button v-if="ticket.status === 10 && canHandle()" @click="openSignIn">ç°åºç¾å°</button><button v-if="ticket.status === 10" @click="classifyVisible = true">é®é¢å¤å®</button><button v-if="ticket.status === 10 && ticket.issueType === 3" @click="createReturn">å建éè´§ç³è¯·</button><button v-if="[10, 30, 40].includes(ticket.status ?? -1) && canHandle()" @click="openHandle">å¤çè®°å½</button><button v-if="ticket.status === 10 && canHandle()" @click="openTransfer">转派</button><button v-if="ticket.status === 10 && canHandle()" @click="openEscalate">å级</button><button v-if="[10, 30, 40].includes(ticket.status ?? -1)" @click="flow('resolve')">å®ç»</button><button v-if="ticket.status === 50 && ticket.visitStatus === 1" @click="flow('close')">彿¡£</button><button v-if="[0, 10].includes(ticket.status ?? -1)" class="danger" @click="flow('cancel')">åæ¶å·¥å</button> |
| | | </view> |
| | | |
| | | <view v-if="classifyVisible" class="form-card"><view class="field"><text>é®é¢ç±»å</text><view class="picker" @click="issueVisible = true"><text>{{ labelOf(ISSUE_TYPES, issueType) }}</text><text>âº</text></view></view><view class="field"><text>é®é¢åç±»</text><input v-model="issueCategory" placeholder="å¦å¤è§ç¼ºé·" /></view><view class="field"><text>严éç¨åº¦</text><view class="picker" @click="severityVisible = true"><text>{{ labelOf(SEVERITIES, severity) }}</text><text>âº</text></view></view><view class="inline-actions"><button class="mini" @click="classify">确认å¤å®</button><button class="mini" @click="classifyVisible = false">åæ¶</button></view></view> |
| | | <view v-if="visitVisible" class="form-card"><view class="field"><text>å访æ¹å¼</text><view class="picker" @click="visitTypeVisible = true"><text>{{ labelOf(VISIT_TYPES, visitForm.visitType) }}</text><text>âº</text></view></view><view class="field column"><text>å访å
容</text><textarea v-model="visitForm.content" placeholder="客æ·åé¦å
容" /></view><view class="field"><text>æ¯å¦æ»¡æ</text><switch :checked="visitForm.satisfied" @change="visitForm.satisfied = $event.detail.value" /></view><view class="field"><text>æ¯å¦å¤å</text><switch :checked="visitForm.recurred" @change="visitForm.recurred = $event.detail.value" /></view><view class="field column"><text>夿³¨</text><textarea v-model="visitForm.remark" placeholder="请è¾å
¥å¤æ³¨" /></view><view class="inline-actions"><button class="mini" @click="submitVisit">æäº¤å访</button><button class="mini" @click="visitVisible = false">åæ¶</button></view></view> |
| | | <view v-if="signInVisible" class="form-card"> |
| | | <view class="field"><text class="required">ç°åºå°å</text><input v-model="signForm.address" placeholder="坿å¨ä¿®æ¹å®ä½å°å" /></view> |
| | | <view class="field"><text>å®ä½åæ </text><text class="value">{{ signForm.longitude ? `${signForm.longitude}, ${signForm.latitude}` : "æªè·åå°å®ä½" }}</text></view> |
| | | <view class="field column"><text>ç¾å°è¯´æ</text><textarea v-model="signForm.content" placeholder="å¦ï¼å·²å°è¾¾å®¢æ·ç°åºï¼å¼å§ææ¥è®¾å¤" /></view> |
| | | <view class="inline-actions"><button class="mini" @click="submitSignIn">确认ç¾å°</button><button class="mini" @click="signInVisible = false">åæ¶</button></view> |
| | | </view> |
| | | <view v-if="transferVisible" class="form-card"> |
| | | <view class="field"><text class="required">æ°å¤ç人</text><view class="picker" @click="handlerSheetVisible = true"><text>{{ users.find(item => item.id === transferForm.handlerUserId)?.nickname || "è¯·éæ©" }}</text><text>âº</text></view></view> |
| | | <view class="field column"><text class="required">转派åå </text><textarea v-model="transferForm.reason" placeholder="å¦ï¼éè¦çµæ°ä¸ä¸åäºå°åºæ¯æ" /></view> |
| | | <view class="inline-actions"><button class="mini" @click="submitTransfer">确认转派</button><button class="mini" @click="transferVisible = false">åæ¶</button></view> |
| | | </view> |
| | | <view v-if="escalateVisible" class="form-card"> |
| | | <view class="field"><text>å级级å«</text><view class="picker" @click="escalateSeverityVisible = true"><text>{{ labelOf(SEVERITIES, escalateForm.severityLevel) }}</text><text>âº</text></view></view> |
| | | <view class="field column"><text class="required">å级åå </text><textarea v-model="escalateForm.reason" placeholder="å¦ï¼ç°åºæ æ³è§£å³ï¼éææ¯ä¸»ç®¡ä»å
¥" /></view> |
| | | <view class="inline-actions"><button class="mini" @click="submitEscalate">确认å级</button><button class="mini" @click="escalateVisible = false">åæ¶</button></view> |
| | | </view> |
| | | <view v-if="handleVisible" class="form-card"> |
| | | <view class="field column"><text class="required">å¤çè®°å½</text><textarea v-model="handleContent" placeholder="è®°å½æ¬æ¬¡å¤çå¨ä½ä¸ç»æï¼ä¾¿äºåç»è¿½æº¯" /></view> |
| | | <view class="inline-actions"><button class="mini" @click="submitHandle">ä¿åè®°å½</button><button class="mini" @click="handleVisible = false">åæ¶</button></view> |
| | | </view> |
| | | </template> |
| | | <view v-else class="state">å è½½ä¸...</view> |
| | | |
| | |
| | | <PickerSheet v-model:show="issueVisible" title="é®é¢ç±»å" :items="ISSUE_TYPES" :selected-keys="issueType ? [issueType] : []" :searchable="false" @confirm="keys => issueType = Number(keys[0])" /> |
| | | <PickerSheet v-model:show="severityVisible" title="严éç¨åº¦" :items="SEVERITIES" :selected-keys="severity !== undefined ? [severity] : []" :searchable="false" @confirm="keys => severity = Number(keys[0])" /> |
| | | <PickerSheet v-model:show="visitTypeVisible" title="å访æ¹å¼" :items="VISIT_TYPES" :selected-keys="[visitForm.visitType]" :searchable="false" @confirm="keys => visitForm.visitType = Number(keys[0])" /> |
| | | <PickerSheet v-model:show="handlerSheetVisible" title="éæ©æ°å¤ç人" :items="users.filter(item => item.id !== ticket?.handlerUserId).map(item => ({ key: item.id, label: item.nickname || item.username || `ç¨æ·${item.id}` }))" :selected-keys="transferForm.handlerUserId ? [transferForm.handlerUserId] : []" search-placeholder="æç´¢äººå" @confirm="keys => transferForm.handlerUserId = Number(keys[0])" /> |
| | | <PickerSheet v-model:show="escalateSeverityVisible" title="å级级å«" :items="SEVERITIES" :selected-keys="escalateForm.severityLevel !== undefined ? [escalateForm.severityLevel] : []" :searchable="false" @confirm="keys => escalateForm.severityLevel = Number(keys[0])" /> |
| | | </view> |
| | | </template> |
| | | |
| | |
| | | @import "../form.scss"; |
| | | .visit-tip{padding:20rpx 24rpx;color:#c26a12;font-size:23rpx;background:#fff8ec}.visit-item{padding:20rpx 24rpx;border-bottom:1rpx solid #eef1f5}.visit-content{display:block;margin-top:12rpx;color:#33455d;font-size:25rpx;line-height:1.6}.visit-button{margin:20rpx}.inline-actions{display:flex;justify-content:flex-end;gap:14rpx;padding:20rpx 24rpx}.inline-actions .mini{margin:0} |
| | | .field switch{margin-left:auto} |
| | | .log-item{position:relative;display:flex;gap:16rpx;padding:20rpx 24rpx 20rpx 30rpx;border-bottom:1rpx solid #eef1f5} |
| | | .log-item:last-child{border-bottom:0} |
| | | .log-dot{width:14rpx;height:14rpx;flex:none;margin-top:10rpx;background:var(--color-primary, #1769d8);border-radius:50%;box-shadow:0 0 0 6rpx #e8f1ff} |
| | | .log-body{min-width:0;flex:1} |
| | | .log-content{display:block;margin-top:10rpx;color:#33455d;font-size:25rpx;line-height:1.6;white-space:pre-wrap} |
| | | </style> |
| | |
| | | import { useAuth } from "../../../stores/auth"; |
| | | import PickerSheet from "../../mes/process-design/components/PickerSheet.vue"; |
| | | import AftersalesAttachmentField from "../AftersalesAttachmentField.vue"; |
| | | import { enqueueTicket, isNetworkError } from "../offline-queue"; |
| | | import { ISSUE_TYPES, SEVERITIES, labelOf, toast } from "../shared"; |
| | | |
| | | interface MaterialChoice { key: number; label: string; code?: string; saleQuantity?: number; saleOrderItemId?: number; batchCode?: string } |
| | |
| | | } |
| | | return true; |
| | | } |
| | | /** æ«ç 䏿¥ï¼æ«éå®è®¢åæ¡ç ï¼å½ä¸åèªå¨å¸¦åºè®¢åã客æ·ãè系人ãååä¸æç» */ |
| | | function scanCode(): Promise<string | null> { |
| | | return new Promise(resolve => { |
| | | // #ifdef H5 |
| | | resolve(null); |
| | | // #endif |
| | | // #ifndef H5 |
| | | uni.scanCode({ |
| | | scanType: ["qrCode", "barCode"], |
| | | success: result => resolve((result.result || "").trim() || null), |
| | | fail: () => resolve(null), |
| | | }); |
| | | // #endif |
| | | }); |
| | | } |
| | | async function scanOrder() { |
| | | if (!state.accessToken) return toast("ç»å½å·²è¿æï¼è¯·éæ°ç»å½"); |
| | | const code = await scanCode(); |
| | | if (!code) { |
| | | // #ifdef H5 |
| | | return toast("H5 ç«¯ä¸æ¯ææ«ç ï¼è¯·æå¨éæ©éå®è®¢å"); |
| | | // #endif |
| | | // #ifndef H5 |
| | | return toast("æªè¯å«å°æ¡ç å
容ï¼è¯·éè¯ææå¨éæ©éå®è®¢å"); |
| | | // #endif |
| | | } |
| | | try { |
| | | let target = orders.value.find(item => item.no === code); |
| | | if (!target) { |
| | | const data = await getSaleOrderPage(state.accessToken, { pageNo: 1, pageSize: 10, no: code, outStatus: 3 }); |
| | | target = (data.list || []).find(item => item.no === code); |
| | | } |
| | | if (!target?.id) return toast(`æªæ¾å°åå·ã${code}ã对åºçå·²åºåºéå®è®¢åï¼è¯·ç¡®è®¤æ¡ç åæå¨éæ©`); |
| | | if (!orders.value.some(item => item.id === target!.id)) orders.value = [target, ...orders.value]; |
| | | await selectOrder([target.id]); |
| | | toast(`已带åºéå®è®¢å ${target.no}`); |
| | | } catch (cause) { |
| | | toast(cause instanceof Error ? cause.message : "æ«ç æ¥è¯¢è®¢å失败"); |
| | | } |
| | | } |
| | | async function submit() { |
| | | if (!state.accessToken || submitting.value || !validate()) return; |
| | | submitting.value = true; |
| | | try { id.value ? await updateTicket(state.accessToken, { ...form.value, id: id.value }) : await createTicket(state.accessToken, form.value); toast("ä¿åæå"); setTimeout(() => uni.navigateBack(), 500); } |
| | | catch (cause) { toast(cause instanceof Error ? cause.message : "ä¿å失败"); } |
| | | finally { submitting.value = false; } |
| | | try { |
| | | if (id.value) await updateTicket(state.accessToken, { ...form.value, id: id.value }); |
| | | else await createTicket(state.accessToken, form.value); |
| | | toast("ä¿åæå"); setTimeout(() => uni.navigateBack(), 500); |
| | | } catch (cause) { |
| | | // 䏿¥å·¥åéå°æç½æ¶å
è½æ¬å°éåï¼æ¢å¤ç½ç»åç±å·¥åå表èªå¨è¡¥ä¼ |
| | | if (!id.value && isNetworkError(cause)) { |
| | | enqueueTicket(form.value); |
| | | toast("å½åæ ç½ç»ï¼å·¥åå·²æåæ¬å°ï¼èç½åèªå¨è¡¥ä¼ "); |
| | | setTimeout(() => uni.navigateBack(), 800); |
| | | return; |
| | | } |
| | | toast(cause instanceof Error ? cause.message : "ä¿å失败"); |
| | | } finally { submitting.value = false; } |
| | | } |
| | | onLoad(async query => { |
| | | id.value = query?.id ? Number(query.id) : undefined; uni.setNavigationBarTitle({ title: id.value ? "ç¼è¾å®åå·¥å" : "æ°å¢å®åå·¥å" }); |
| | |
| | | <view class="page"> |
| | | <view class="form-card"> |
| | | <view class="field"><text class="required">å·¥å主é¢</text><input v-model="form.name" placeholder="请è¾å
¥å·¥å主é¢" /></view> |
| | | <view class="field"><text>éå®è®¢åå·</text><view class="picker" @click="orderVisible = true"><text>{{ form.saleOrderNo || "è¯·éæ©éå®è®¢åï¼å¯éï¼" }}</text><text>âº</text></view></view> |
| | | <view class="field"><text>éå®è®¢åå·</text><view class="picker" @click="orderVisible = true"><text>{{ form.saleOrderNo || "è¯·éæ©éå®è®¢åï¼å¯éï¼" }}</text><text>âº</text></view><text class="scan-link" @click.stop="scanOrder">æ«ç </text></view> |
| | | <view class="field"><text class="required">客æ·</text><view class="picker" @click="customerVisible = true"><text>{{ form.customerName || "è¯·éæ©å®¢æ·" }}</text><text>âº</text></view></view> |
| | | <view class="field"><text>è系人</text><view class="picker" @click="contactVisible = true"><text>{{ contactLabel }}</text><text>âº</text></view></view> |
| | | <view class="field"><text>ååç¼å·</text><view class="picker" @click="contractVisible = true"><text>{{ contractLabel }}</text><text>âº</text></view></view> |
| | |
| | | <PickerSheet v-model:show="batchVisible" title="éæ©æ¹æ¬¡" :items="batches.map(item => ({ key: item.id || 0, label: item.code || 'æªå½åæ¹æ¬¡', sub: item.produceDate || item.receiptDate }))" :selected-keys="activeLine >= 0 && form.items?.[activeLine]?.batchId ? [form.items[activeLine].batchId as number] : []" search-placeholder="æç´¢æ¹æ¬¡" @confirm="selectBatch" /> |
| | | </view> |
| | | </template> |
| | | <style scoped lang="scss">@import "../form.scss";</style> |
| | | <style scoped lang="scss">@import "../form.scss"; |
| | | .scan-link{flex:none;padding:8rpx 16rpx;color:var(--color-primary, #1769d8);font-size:23rpx;background:#eef5ff;border-radius:999rpx} |
| | | </style> |
| | |
| | | import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app"; |
| | | import { cancelTicket, deleteTicket, getTicketPage, type AftersalesTicket } from "../../../api/aftersales"; |
| | | import { useAuth } from "../../../stores/auth"; |
| | | import { flushQueue, queueCount } from "../offline-queue"; |
| | | import { ISSUE_TYPES, SEVERITIES, TICKET_STATUS, confirm, dateTime, labelOf, toast } from "../shared"; |
| | | |
| | | const { hasPermission, state } = useAuth(); |
| | | const pendingCount = ref(0); |
| | | const rows = ref<AftersalesTicket[]>([]); |
| | | const keyword = ref(""); |
| | | const sceneType = ref("1"); |
| | |
| | | } catch (cause) { toast(cause instanceof Error ? cause.message : `${label}失败`); } |
| | | } |
| | | function tag(status?: number) { return status === 50 ? "tag-green" : status === 99 ? "tag-danger" : status === 30 || status === 40 ? "tag-orange" : status === 10 ? "tag-blue" : "tag-gray"; } |
| | | onShow(() => load(true)); |
| | | async function syncOffline() { |
| | | pendingCount.value = queueCount(); |
| | | if (!state.accessToken || !pendingCount.value) return; |
| | | const result = await flushQueue(state.accessToken); |
| | | pendingCount.value = result.remaining; |
| | | if (result.synced) { |
| | | toast(`å·²è¡¥ä¼ ${result.synced} æ¡ç¦»çº¿å·¥å`); |
| | | await load(true); |
| | | } |
| | | } |
| | | onShow(async () => { await load(true); await syncOffline(); }); |
| | | onPullDownRefresh(() => load(true)); |
| | | onReachBottom(() => { if (rows.value.length < total.value && !loading.value) { pageNo.value += 1; load(); } }); |
| | | </script> |
| | |
| | | <text v-for="scene in scenes" :key="scene.key" :class="{ active: sceneType === scene.key }" @click="changeScene(scene.key)">{{ scene.label }}</text> |
| | | </scroll-view> |
| | | <view class="search"><input v-model="keyword" placeholder="æç´¢å·¥ååå·" confirm-type="search" @confirm="load(true)" /><text @click="load(true)">æç´¢</text></view> |
| | | <view v-if="pendingCount" class="offline-tip" @click="syncOffline">æ {{ pendingCount }} æ¡ç¦»çº¿å·¥åå¾
è¡¥ä¼ ï¼èç½åèªå¨åæ¥ï¼ç¹å»ç«å³éè¯ï¼</view> |
| | | <view v-if="error" class="state error">{{ error }}</view> |
| | | <template v-else> |
| | | <view v-for="row in rows" :key="row.id" class="card" @click="go('detail', row.id)"> |
| | |
| | | <view v-else class="state">ææ å®å工忥çæé</view> |
| | | </view> |
| | | </template> |
| | | <style scoped lang="scss">@import "../list.scss";</style> |
| | | <style scoped lang="scss">@import "../list.scss"; |
| | | .offline-tip{padding:16rpx 20rpx;margin-bottom:18rpx;color:#c26a12;font-size:23rpx;background:#fff8ec;border-radius:10rpx} |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, ref } from "vue"; |
| | | import { onPullDownRefresh, onShow } from "@dcloudio/uni-app"; |
| | | import { getTicketStatistics, type AftersalesTicketStatistics } from "../../../api/aftersales"; |
| | | import { useAuth } from "../../../stores/auth"; |
| | | |
| | | const { hasPermission, state } = useAuth(); |
| | | const sceneType = ref("4"); |
| | | const data = ref<AftersalesTicketStatistics | null>(null); |
| | | const loading = ref(false); |
| | | const error = ref(""); |
| | | const scenes = [{ key: "1", label: "æè´è´£ç" }, { key: "2", label: "æåä¸ç" }, { key: "3", label: "ä¸å±è´è´£ç" }, { key: "4", label: "å
¨é¨" }]; |
| | | const canQuery = computed(() => hasPermission("aftersales:ticket:query")); |
| | | |
| | | const kpis = computed(() => { |
| | | const source = data.value; |
| | | if (!source) return []; |
| | | return [ |
| | | { label: "工忻é", value: source.totalCount, unit: "å", tone: "blue" }, |
| | | { label: "å¤çä¸", value: source.processingCount, unit: "å", tone: "orange" }, |
| | | { label: "仿¥ä¸æ¥", value: source.todayCreateCount, unit: "å", tone: "cyan" }, |
| | | { label: "仿¥å®ç»", value: source.todayResolveCount, unit: "å", tone: "green" }, |
| | | { label: "ç°åºç¾å°", value: source.signInCount, unit: "次", tone: "violet" }, |
| | | { label: "å级工å", value: source.escalatedCount, unit: "å", tone: "rose" }, |
| | | { label: "å¹³åå¤ç", value: Number(source.avgResolveHours || 0).toFixed(1), unit: "å°æ¶", tone: "indigo" }, |
| | | { label: "å访满æåº¦", value: Number(source.satisfactionRate || 0).toFixed(1), unit: "%", tone: "green" }, |
| | | ]; |
| | | }); |
| | | |
| | | const statusRows = computed(() => (data.value?.statusCounts || []).filter(item => item.count > 0)); |
| | | const issueRows = computed(() => (data.value?.issueTypeCounts || []).filter(item => item.count > 0)); |
| | | |
| | | function ratio(count: number, total: number) { |
| | | if (!total) return 0; |
| | | return Math.max(2, Math.round((count / total) * 100)); |
| | | } |
| | | |
| | | async function load() { |
| | | if (!state.accessToken || !canQuery.value || loading.value) return; |
| | | loading.value = true; |
| | | error.value = ""; |
| | | try { |
| | | data.value = await getTicketStatistics(state.accessToken, sceneType.value); |
| | | } catch (cause) { |
| | | error.value = cause instanceof Error ? cause.message : "ç»è®¡å 载失败"; |
| | | } finally { |
| | | loading.value = false; |
| | | uni.stopPullDownRefresh(); |
| | | } |
| | | } |
| | | function changeScene(key: string) { |
| | | sceneType.value = key; |
| | | data.value = null; |
| | | load(); |
| | | } |
| | | onShow(() => load()); |
| | | onPullDownRefresh(() => load()); |
| | | </script> |
| | | |
| | | <template> |
| | | <view class="page"> |
| | | <template v-if="state.accessToken && canQuery"> |
| | | <scroll-view class="scene-tabs" scroll-x> |
| | | <text v-for="scene in scenes" :key="scene.key" :class="{ active: sceneType === scene.key }" @click="changeScene(scene.key)">{{ scene.label }}</text> |
| | | </scroll-view> |
| | | <view v-if="error" class="state error" @click="load">{{ error }}ï¼ç¹å»éè¯ï¼</view> |
| | | <view v-else-if="!data && loading" class="state">æ£å¨æ±æ»å·¥åæ°æ®...</view> |
| | | <template v-else-if="data"> |
| | | <view class="kpi-grid"> |
| | | <view v-for="item in kpis" :key="item.label" :class="['kpi', item.tone]"> |
| | | <text class="kpi-label">{{ item.label }}</text> |
| | | <view class="kpi-value"><text>{{ item.value }}</text><text class="unit">{{ item.unit }}</text></view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="panel"> |
| | | <view class="panel-head"><text>å·¥åç¶æåå¸</text><text>å
± {{ data.totalCount }} å</text></view> |
| | | <view v-for="item in statusRows" :key="item.status" class="bar-row"> |
| | | <text class="bar-label">{{ item.statusName }}</text> |
| | | <view class="bar-track"><view class="bar-fill blue" :style="{ width: `${ratio(item.count, data.totalCount)}%` }" /></view> |
| | | <text class="bar-value">{{ item.count }}</text> |
| | | </view> |
| | | <view v-if="!statusRows.length" class="empty">ææ ç¶ææ°æ®</view> |
| | | </view> |
| | | |
| | | <view class="panel"> |
| | | <view class="panel-head"><text>é®é¢ç±»ååå¸</text><text>é®é¢å¤å®åç»è®¡</text></view> |
| | | <view v-for="item in issueRows" :key="item.issueType" class="bar-row"> |
| | | <text class="bar-label">{{ item.issueTypeName }}</text> |
| | | <view class="bar-track"><view class="bar-fill cyan" :style="{ width: `${ratio(item.count, data.totalCount)}%` }" /></view> |
| | | <text class="bar-value">{{ item.count }}</text> |
| | | </view> |
| | | <view v-if="!issueRows.length" class="empty">ææ å·²å¤å®é®é¢ç±»åçå·¥å</view> |
| | | </view> |
| | | |
| | | <view class="panel"> |
| | | <view class="panel-head"><text>å¤ç人工ä½é</text><text>æå¤çä¸ / 累计</text></view> |
| | | <view v-for="item in data.handlerWorkloads" :key="item.handlerUserId" class="person"> |
| | | <view class="avatar">{{ (item.handlerUserName || "æªææ´¾").slice(0, 1) }}</view> |
| | | <text class="person-name">{{ item.handlerUserName || "æªææ´¾" }}</text> |
| | | <text class="person-count">å¤çä¸ {{ item.processingCount }} · 累计 {{ item.totalCount }}</text> |
| | | </view> |
| | | <view v-if="!data.handlerWorkloads.length" class="empty">ææ å¤ç人工ä½éæ°æ®</view> |
| | | </view> |
| | | |
| | | <view class="tip">æ°æ®èå´ï¼{{ scenes.find(scene => scene.key === sceneType)?.label }}ï¼ä¸æå¯å·æ°ã</view> |
| | | </template> |
| | | </template> |
| | | <view v-else class="state">ææ å·¥åç»è®¡æ¥çæé</view> |
| | | </view> |
| | | </template> |
| | | |
| | | <style scoped lang="scss"> |
| | | .page{min-height:100vh;padding:20rpx 30rpx 80rpx;box-sizing:border-box;background:#f3f6fb} |
| | | .scene-tabs{height:88rpx;white-space:nowrap} |
| | | .scene-tabs text{display:inline-block;padding:27rpx 20rpx 18rpx;color:#718198;font-size:27rpx} |
| | | .scene-tabs .active{color:var(--color-primary, #1769d8);font-weight:600;border-bottom:4rpx solid var(--color-primary, #1769d8)} |
| | | .kpi-grid{display:grid;grid-template-columns:1fr 1fr;gap:16rpx} |
| | | .kpi{padding:22rpx;background:#fff;border-radius:14rpx;border-left:6rpx solid #1769d8;box-shadow:0 8rpx 20rpx rgba(43,69,107,.06)} |
| | | .kpi.blue{border-color:#1769d8}.kpi.orange{border-color:#df8919}.kpi.cyan{border-color:#268ca7}.kpi.green{border-color:#259265}.kpi.violet{border-color:#6f60d9}.kpi.rose{border-color:#ce5c83}.kpi.indigo{border-color:#526fc1} |
| | | .kpi-label{color:#78879b;font-size:23rpx} |
| | | .kpi-value{display:flex;align-items:baseline;gap:6rpx;margin-top:10rpx} |
| | | .kpi-value text{color:#20334c;font-size:44rpx;font-weight:700;line-height:1.1} |
| | | .kpi-value .unit{color:#8a98ab;font-size:22rpx;font-weight:500} |
| | | .panel{margin-top:20rpx;padding:24rpx 22rpx;background:#fff;border-radius:14rpx;box-shadow:0 8rpx 20rpx rgba(43,69,107,.06)} |
| | | .panel-head{display:flex;align-items:baseline;justify-content:space-between;padding-bottom:16rpx;margin-bottom:8rpx;border-bottom:1rpx solid #edf1f6} |
| | | .panel-head text:first-child{color:#20334c;font-size:28rpx;font-weight:700} |
| | | .panel-head text:last-child{color:#8a98ab;font-size:21rpx} |
| | | .bar-row{display:flex;align-items:center;gap:14rpx;padding:14rpx 0} |
| | | .bar-label{width:150rpx;flex:none;color:#5f7089;font-size:23rpx;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| | | .bar-track{height:16rpx;flex:1;overflow:hidden;background:#eef2f8;border-radius:8rpx} |
| | | .bar-fill{height:100%;border-radius:8rpx} |
| | | .bar-fill.blue{background:#1769d8}.bar-fill.cyan{background:#268ca7} |
| | | .bar-value{width:70rpx;flex:none;color:#20334c;font-size:23rpx;font-weight:600;text-align:right} |
| | | .person{display:flex;align-items:center;gap:14rpx;padding:14rpx 0;border-bottom:1rpx solid #f1f4f8} |
| | | .person:last-child{border-bottom:0} |
| | | .avatar{display:flex;width:56rpx;height:56rpx;flex:none;align-items:center;justify-content:center;color:#1769d8;font-size:24rpx;font-weight:700;background:#e8f1ff;border-radius:50%} |
| | | .person-name{flex:1;color:#3c4b60;font-size:26rpx;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| | | .person-count{color:#78879b;font-size:22rpx} |
| | | .empty{padding:30rpx 0;color:#8795a8;font-size:24rpx;text-align:center} |
| | | .state{display:block;padding:60rpx 0;color:#8795a8;font-size:26rpx;text-align:center} |
| | | .error{color:#c65050} |
| | | .tip{padding:28rpx 0 0;color:#93a0b2;font-size:21rpx;text-align:center} |
| | | </style> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, ref } from "vue"; |
| | | import { onPullDownRefresh, onShow } from "@dcloudio/uni-app"; |
| | | |
| | | import { getTicketStatistics, type AftersalesTicketStatistics } from "../../api/aftersales"; |
| | | import { APP_COMPANY } from "../../config/company"; |
| | | import { useAuth } from "../../stores/auth"; |
| | | import { useNotify } from "../../stores/notify"; |
| | | |
| | | interface ModuleEntry { |
| | | name: string; |
| | | detail: string; |
| | | route: string; |
| | | mark: string; |
| | | tone: string; |
| | | } |
| | | |
| | | /** é¦é¡µåªåç°ãææ¯æ¹æ¡-è¯åäºéï¼2026ï¼ãç 8 大模å */ |
| | | const modules: ModuleEntry[] = [ |
| | | { name: "计åæç¨", detail: "ç产计å", route: "/pages/mes/pro/mps/index", mark: "计", tone: "blue" }, |
| | | { name: "ç产管æ§", detail: "å·¥å · æ¥å·¥", route: "/pages/mes/pro/workorder/index", mark: "产", tone: "indigo" }, |
| | | { name: "设å¤ç®¡ç", detail: "å°è´¦ · ç¹æ£", route: "/pages/mes/dv/machinery/index", mark: "设", tone: "cyan" }, |
| | | { name: "éè´ç®¡ç", detail: "ç³è¯· · 订å", route: "/pages/purchase/order/index", mark: "é", tone: "violet" }, |
| | | { name: "ä»å¨ç©æµ", detail: "åºå · åºå
¥åº", route: "/pages/wls/material-stock/index", mark: "ä»", tone: "teal" }, |
| | | { name: "è¥é管ç", detail: "å®¢æ· Â· 订å", route: "/pages/crm/customer/index", mark: "è¥", tone: "rose" }, |
| | | { name: "人åèµæº", detail: "åå·¥ · èå¤", route: "/pages/hrm/employee/index", mark: "人", tone: "amber" }, |
| | | { name: "å·¥å婿", detail: "䏿¥ · åç", route: "/pages/aftersales/ticket/index", mark: "å", tone: "orange" }, |
| | | ]; |
| | | |
| | | const { isLoggedIn, hasPermission, restoreUser, state } = useAuth(); |
| | | const { state: notifyState, refreshUnreadCount } = useNotify(); |
| | | const ticketStats = ref<AftersalesTicketStatistics | null>(null); |
| | | const loading = ref(false); |
| | | const errorText = ref(""); |
| | | |
| | | const userName = computed(() => state.user?.nickname || state.user?.username || "åäº"); |
| | | const greeting = computed(() => { |
| | | const hour = new Date().getHours(); |
| | | if (hour < 6) return "夿·±äº"; |
| | | if (hour < 9) return "æ©ä¸å¥½"; |
| | | if (hour < 12) return "ä¸å好"; |
| | | if (hour < 14) return "ä¸å好"; |
| | | if (hour < 18) return "ä¸å好"; |
| | | return "æä¸å¥½"; |
| | | }); |
| | | const today = computed(() => { |
| | | const value = new Date(); |
| | | const weeks = ["æ¥", "ä¸", "äº", "ä¸", "å", "äº", "å
"]; |
| | | return `${value.getMonth() + 1}æ${value.getDate()}æ¥ ææ${weeks[value.getDay()]}`; |
| | | }); |
| | | const canQueryTicket = computed(() => hasPermission("aftersales:ticket:query")); |
| | | const kpis = computed(() => { |
| | | const source = ticketStats.value; |
| | | return [ |
| | | { label: "å¤çä¸", value: source?.processingCount ?? 0, unit: "å", tone: "blue" }, |
| | | { label: "å¾
å访", value: source?.completedCount ?? 0, unit: "å", tone: "amber" }, |
| | | { label: "仿¥ä¸æ¥", value: source?.todayCreateCount ?? 0, unit: "å", tone: "cyan" }, |
| | | { label: "仿¥å®ç»", value: source?.todayResolveCount ?? 0, unit: "å", tone: "green" }, |
| | | ]; |
| | | }); |
| | | const quickActions = computed(() => |
| | | [ |
| | | { name: "å·¥å䏿¥", detail: "æ«ç ææå¨å¡«æ¥", route: "/pages/aftersales/ticket/form", permission: "aftersales:ticket:create", tone: "orange", mark: "æ¥" }, |
| | | { name: "å·¥ååç", detail: "æ´¾å · ç¾å° · å¤ç", route: "/pages/aftersales/ticket/index", permission: "aftersales:ticket:query", tone: "blue", mark: "å" }, |
| | | { name: "å·¥ååæ", detail: "è¿åº¦ · å·¥ä½é", route: "/pages/aftersales/ticket/statistics", permission: "aftersales:ticket:query", tone: "violet", mark: "æ" }, |
| | | { name: "é®é¢åº", detail: "åå²é®é¢åè", route: "/pages/aftersales/problem/index", permission: "aftersales:problem:query", tone: "rose", mark: "åº" }, |
| | | ].filter(item => hasPermission(item.permission)) |
| | | ); |
| | | |
| | | function openRoute(route: string) { |
| | | uni.navigateTo({ url: route }); |
| | | } |
| | | function goMessages() { |
| | | uni.switchTab({ url: "/pages/message/index" }); |
| | | } |
| | | function goWorkbench() { |
| | | uni.switchTab({ url: "/pages/workbench/index" }); |
| | | } |
| | | function goLogin() { |
| | | uni.navigateTo({ url: "/pages/login/index" }); |
| | | } |
| | | |
| | | async function refreshPage() { |
| | | if (loading.value) return; |
| | | loading.value = true; |
| | | errorText.value = ""; |
| | | try { |
| | | await restoreUser(); |
| | | const token = state.accessToken; |
| | | if (!token) return; |
| | | if (canQueryTicket.value) { |
| | | try { |
| | | ticketStats.value = await getTicketStatistics(token, 4); |
| | | } catch { |
| | | errorText.value = "å·¥åæ°æ®ææ¶æ æ³è·å"; |
| | | } |
| | | } |
| | | try { |
| | | await refreshUnreadCount(); |
| | | } catch { |
| | | /* æ¶æ¯å¼å¸¸ä¸å½±åé¦é¡µ */ |
| | | } |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | onShow(refreshPage); |
| | | onPullDownRefresh(async () => { |
| | | await refreshPage(); |
| | | uni.stopPullDownRefresh(); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <view class="page"> |
| | | <template v-if="isLoggedIn"> |
| | | <view class="hero"> |
| | | <view class="top-line"> |
| | | <view class="brand"><view class="brand-mark">è¯</view><view><text>{{ APP_COMPANY.name }}</text><text>ç产è¿è¥å·¥ä½å°</text></view></view> |
| | | <view class="notice" @click="goMessages"><view class="bell"><view /></view><view v-if="notifyState.unreadCount" class="notice-dot" /></view> |
| | | </view> |
| | | <text class="greeting">{{ greeting }}ï¼{{ userName }}</text> |
| | | <text class="date">{{ today }} · 计åãç产ã设å¤ä¸å·¥å婿</text> |
| | | <view class="system-line"><view /><text>ç³»ç»è¿è¡æ£å¸¸</text><text>{{ loading ? "æ°æ®æ´æ°ä¸" : "æ°æ®å·²åæ¥" }}</text></view> |
| | | </view> |
| | | |
| | | <view v-if="canQueryTicket" class="ticket-board" @click="openRoute('/pages/aftersales/ticket/statistics')"> |
| | | <view class="board-title"><view><text>å·¥å婿</text><text>å®åå·¥å宿¶è¿åº¦</text></view><text>çåæ âº</text></view> |
| | | <view class="kpi-row"> |
| | | <view v-for="item in kpis" :key="item.label" class="kpi-item"> |
| | | <text>{{ item.label }}</text> |
| | | <strong>{{ item.value }}<text class="unit">{{ item.unit }}</text></strong> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view v-if="errorText" class="error" @click="refreshPage">{{ errorText }}ï¼ç¹å»éè¯</view> |
| | | |
| | | <view class="section"> |
| | | <view class="section-head"><view><text>ä¸å¡æ¨¡å</text><text>ææ¹æ¡ 8 大模åç»ç»</text></view><text @click="goWorkbench">å
¨é¨åºç¨</text></view> |
| | | <view class="module-grid"> |
| | | <view v-for="module in modules" :key="module.name" class="module-item" @click="openRoute(module.route)"> |
| | | <view :class="['module-mark', module.tone]">{{ module.mark }}</view> |
| | | <view class="module-copy"><text>{{ module.name }}</text><text>{{ module.detail }}</text></view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | |
| | | <view v-if="quickActions.length" class="section"> |
| | | <view class="section-head"><view><text>å·¥åå©æå¿«æ·å
¥å£</text><text>䏿¥ · åç · åæ</text></view><text @click="openRoute('/pages/aftersales/ticket/index')">å·¥åå表</text></view> |
| | | <view class="quick-grid"> |
| | | <view v-for="item in quickActions" :key="item.name" class="quick-item" @click="openRoute(item.route)"> |
| | | <view :class="['quick-mark', item.tone]">{{ item.mark }}</view> |
| | | <text>{{ item.name }}</text> |
| | | <text>{{ item.detail }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <view v-else class="login-state"> |
| | | <view class="login-logo">è¯</view> |
| | | <text>{{ APP_COMPANY.name }}</text> |
| | | <text>ç»å½åæ¥çç产è¿è¥ä¸å·¥åå©ææ°æ®</text> |
| | | <button @click="goLogin">ç«å³ç»å½</button> |
| | | </view> |
| | | </view> |
| | | </template> |
| | | |
| | | <style lang="scss" scoped> |
| | | .page{box-sizing:border-box;min-height:100vh;overflow-x:hidden;padding:calc(var(--status-bar-height) + 20rpx) 26rpx 156rpx;background:linear-gradient(180deg,#dbe9ff 0,#eef4fb 30%,#f3f5f8 60%)} |
| | | .hero{margin:-20rpx -26rpx 0;padding:calc(var(--status-bar-height) + 28rpx) 34rpx 76rpx} |
| | | .top-line,.brand,.system-line,.section-head,.section-head>view,.board-title{display:flex;align-items:center} |
| | | .top-line,.board-title,.section-head{justify-content:space-between} |
| | | .brand{gap:14rpx} |
| | | .brand-mark,.login-logo{display:flex;align-items:center;justify-content:center;color:#fff;font-weight:800;background:linear-gradient(145deg,#1a5fc4,#2c86d8)} |
| | | .brand-mark{width:54rpx;height:54rpx;font-size:25rpx;border-radius:12rpx;box-shadow:0 8rpx 18rpx rgba(26,95,196,.22)} |
| | | .brand text{display:block} |
| | | .brand text:first-child{font-size:26rpx;font-weight:750} |
| | | .brand text:last-child{margin-top:3rpx;color:#6a7f96;font-size:19rpx} |
| | | .greeting{display:block;margin-top:31rpx;color:#16324f;font-size:39rpx;font-weight:750} |
| | | .date{display:block;margin-top:8rpx;color:#607c97;font-size:22rpx} |
| | | .notice{position:relative;display:flex;width:68rpx;height:68rpx;align-items:center;justify-content:center;background:rgba(255,255,255,.9);border-radius:50%;box-shadow:0 8rpx 20rpx rgba(31,91,140,.12)} |
| | | .bell{position:relative;width:25rpx;height:27rpx;border:3rpx solid #29435f;border-bottom:0;border-radius:14rpx 14rpx 0 0} |
| | | .bell::before{position:absolute;right:-6rpx;bottom:-4rpx;left:-6rpx;height:3rpx;content:"";background:#29435f;border-radius:3rpx} |
| | | .bell view{position:absolute;bottom:-10rpx;left:9rpx;width:7rpx;height:7rpx;background:#29435f;border-radius:50%} |
| | | .notice-dot{position:absolute;top:9rpx;right:9rpx;width:14rpx;height:14rpx;background:#ef5350;border:3rpx solid #fff;border-radius:50%} |
| | | .system-line{gap:8rpx;margin-top:22rpx;color:#4a7a92;font-size:19rpx} |
| | | .system-line view{width:10rpx;height:10rpx;background:#18ad78;border-radius:50%;box-shadow:0 0 0 7rpx rgba(24,173,120,.1)} |
| | | .system-line text:last-child{margin-left:auto;color:#7b93a8} |
| | | .ticket-board{margin-top:-46rpx;padding:24rpx 24rpx 0;overflow:hidden;background:#fff;border-radius:16rpx;box-shadow:0 18rpx 42rpx rgba(37,80,113,.12)} |
| | | .board-title>view text{display:block} |
| | | .board-title>view text:first-child{color:#16324f;font-size:29rpx;font-weight:700} |
| | | .board-title>view text:last-child{margin-top:5rpx;color:#73879c;font-size:19rpx} |
| | | .board-title>text{color:#1769d8;font-size:21rpx} |
| | | .kpi-row{display:grid;grid-template-columns:repeat(4,1fr);margin:22rpx -24rpx 0;border-top:1rpx solid #e9eff5;background:#f8fbfe} |
| | | .kpi-item{padding:20rpx 8rpx 22rpx;text-align:center} |
| | | .kpi-item+.kpi-item{border-left:1rpx solid #e9eff5} |
| | | .kpi-item>text{display:block;color:#7a8b9e;font-size:19rpx} |
| | | .kpi-item strong{display:block;margin-top:8rpx;color:#1769d8;font-size:36rpx;line-height:1.1} |
| | | .kpi-item .unit{display:inline;margin-left:3rpx;color:#8698aa;font-size:17rpx;font-weight:500} |
| | | .error{margin-top:18rpx;padding:16rpx 20rpx;color:#b63d3d;font-size:22rpx;background:#fff1f0;border-left:6rpx solid #e45454} |
| | | .section{margin-top:30rpx} |
| | | .section-head{margin:0 2rpx 15rpx} |
| | | .section-head>view{gap:14rpx} |
| | | .section-head>view text:first-child{color:#16324f;font-size:29rpx;font-weight:700} |
| | | .section-head>view text:last-child,.section-head>text{color:#74899f;font-size:20rpx} |
| | | .module-grid{display:grid;grid-template-columns:1fr 1fr;gap:14rpx} |
| | | .module-item{display:flex;min-width:0;align-items:center;gap:16rpx;padding:20rpx 18rpx;background:#fff;border:1rpx solid #e0e8f0;border-radius:14rpx} |
| | | .module-item:active{background:#f3f8ff} |
| | | .module-mark{display:flex;width:58rpx;height:58rpx;flex:none;align-items:center;justify-content:center;color:#fff;font-size:24rpx;font-weight:700;border-radius:14rpx} |
| | | .module-copy{display:flex;min-width:0;flex-direction:column} |
| | | .module-copy text:first-child{color:#1e3049;font-size:27rpx;font-weight:650} |
| | | .module-copy text:last-child{margin-top:5rpx;color:#8697a9;font-size:19rpx;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| | | .quick-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14rpx} |
| | | .quick-item{min-width:0;padding:20rpx 18rpx;background:#fff;border:1rpx solid #e0e8f0;border-radius:14rpx} |
| | | .quick-mark{display:flex;width:52rpx;height:52rpx;align-items:center;justify-content:center;color:#fff;font-size:22rpx;font-weight:700;border-radius:12rpx} |
| | | .quick-item>text{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| | | .quick-item>text:nth-child(2){margin-top:12rpx;color:#1e3049;font-size:23rpx;font-weight:650} |
| | | .quick-item>text:last-child{margin-top:3rpx;color:#8697a9;font-size:18rpx} |
| | | .blue{background:#1769d8}.indigo{background:#526fc1}.cyan{background:#268ca7}.violet{background:#6f60d9}.teal{background:#14a584}.rose{background:#ce5c83}.amber{background:#dfa025}.orange{background:#df8919}.green{background:#259265} |
| | | .login-state{display:flex;min-height:72vh;flex-direction:column;align-items:center;justify-content:center} |
| | | .login-logo{width:104rpx;height:104rpx;font-size:46rpx;border-radius:22rpx} |
| | | .login-state>text:nth-child(2){margin-top:24rpx;color:#16324f;font-size:34rpx;font-weight:700} |
| | | .login-state>text:nth-child(3){margin-top:8rpx;color:#7e90a3;font-size:22rpx} |
| | | .login-state button{width:360rpx;margin-top:38rpx;color:#fff;font-size:25rpx;background:#1474dc} |
| | | </style> |
| | |
| | | <script setup lang="ts"> |
| | | import { APP_COMPANY } from "../../config/company"; |
| | | import ChanghuiPowerHome from "./changhui-power.vue"; |
| | | import ChenfaHome from "./chenfa.vue"; |
| | | import ClassicHome from "./classic.vue"; |
| | | import KangyiIntelligentHome from "./kangyi-intelligent.vue"; |
| | | import LanyuPaperHome from "./lanyu-paper.vue"; |
| | |
| | | </script> |
| | | |
| | | <template> |
| | | <XingaoPowerHome v-if="APP_COMPANY.homeVariant === 'xingao-power'" /> |
| | | <ChenfaHome v-if="APP_COMPANY.homeVariant === 'chenfa-tools'" /> |
| | | <XingaoPowerHome v-else-if="APP_COMPANY.homeVariant === 'xingao-power'" /> |
| | | <ChanghuiPowerHome v-else-if="APP_COMPANY.homeVariant === 'changhui-power'" /> |
| | | <NorthTechHome v-else-if="APP_COMPANY.homeVariant === 'north-tech'" /> |
| | | <ZhongyiAssemblyHome v-else-if="APP_COMPANY.homeVariant === 'zhongyi-assembly'" /> |
| | |
| | | icon: string; |
| | | shape: string; |
| | | mark: string; |
| | | /** å
ç½®å
¥å£å¯ç´æ¥æå®è·¯ç±ï¼ä¸ä¾èµ Web èåç»ä»¶æ å° */ |
| | | route?: string; |
| | | } |
| | | |
| | | const { isLoggedIn, refreshPermissionInfo, state } = useAuth(); |
| | | /** æãææ¯æ¹æ¡-è¯åäºéï¼2026ï¼ãåºå®ä¸º 8 大ä¸å¡æ¨¡åï¼é¡ºåºå³å±ç¤ºé¡ºåº */ |
| | | const MODULE_GROUPS = [ |
| | | "计åæç¨", |
| | | "ç产管æ§", |
| | | "设å¤ç®¡ç", |
| | | "éè´ç®¡ç", |
| | | "ä»å¨ç©æµ", |
| | | "è¥é管ç", |
| | | "人åèµæº", |
| | | "å·¥å婿", |
| | | ]; |
| | | |
| | | const { isLoggedIn, refreshPermissionInfo, hasPermission, state } = useAuth(); |
| | | // æªå¼åæ¨¡åæ¾ç¤ºå¼å
³ï¼é»è®¤éèãåç»å¼åæ°æ¨¡åæ¶æ¹ä¸º trueï¼å¯å¨å·¥ä½å°çå°æªæ å° app 页é¢å
¥å£ã |
| | | const SHOW_UNDEVELOPED_MODULES = false; |
| | | const keyword = ref(""); |
| | |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * æå端ä¸çº§ç®å½æ¶æå° 8 大模åï¼ |
| | | * - åºç¡æ°æ®ä¸æ¯æ¹æ¡éçç¬ç«æ¨¡åï¼æä¸å¡å½å±å¹¶å
¥çäº§ç®¡æ§ / 设å¤ç®¡çï¼é¿å
App åºç°å¤ç«çåºç¡æ°æ®å
¥å£ï¼ |
| | | * - åååå
¬ãç³»ç»ç®¡ççè¿ç»´ç±»ç®å½ä¸å¨æ¹æ¡èå´å
ï¼App å·¥ä½å°ä¸å±ç¤ºã |
| | | */ |
| | | function resolveModuleGroup(app: WorkbenchApp): string { |
| | | if (MODULE_GROUPS.includes(app.group)) return app.group; |
| | | if (app.group === "åºç¡æ°æ®") { |
| | | return /设å¤ç±»å|mes\/dv\//.test(`${app.name} ${app.component}`) |
| | | ? "设å¤ç®¡ç" |
| | | : "ç产管æ§"; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /** å·¥åå©ææ¯ App å¿
夿¨¡åï¼å端èå为 visible=0 çæéè½½ä½ï¼å
¥å£ç± App å
ç½® */ |
| | | const helperDefinitions: Array<{ |
| | | id: number; |
| | | name: string; |
| | | route: string; |
| | | permission: string; |
| | | tone: string; |
| | | icon: string; |
| | | shape: string; |
| | | mark: string; |
| | | }> = [ |
| | | { |
| | | id: -7101, |
| | | name: "å·¥å䏿¥", |
| | | route: "/pages/aftersales/ticket/form", |
| | | permission: "aftersales:ticket:create", |
| | | tone: "orange", |
| | | icon: "quality", |
| | | shape: "shape-4", |
| | | mark: "mark-7", |
| | | }, |
| | | { |
| | | id: -7102, |
| | | name: "å·¥ååç", |
| | | route: "/pages/aftersales/ticket/index", |
| | | permission: "aftersales:ticket:query", |
| | | tone: "blue", |
| | | icon: "production", |
| | | shape: "shape-0", |
| | | mark: "mark-12", |
| | | }, |
| | | { |
| | | id: -7103, |
| | | name: "å·¥ååæ", |
| | | route: "/pages/aftersales/ticket/statistics", |
| | | permission: "aftersales:ticket:query", |
| | | tone: "violet", |
| | | icon: "grid", |
| | | shape: "shape-9", |
| | | mark: "mark-3", |
| | | }, |
| | | { |
| | | id: -7104, |
| | | name: "é®é¢åº", |
| | | route: "/pages/aftersales/problem/index", |
| | | permission: "aftersales:problem:query", |
| | | tone: "rose", |
| | | icon: "quality", |
| | | shape: "shape-1", |
| | | mark: "mark-10", |
| | | }, |
| | | { |
| | | id: -7105, |
| | | name: "éè´§ç³è¯·", |
| | | route: "/pages/aftersales/return/index", |
| | | permission: "aftersales:return:query", |
| | | tone: "cyan", |
| | | icon: "warehouse", |
| | | shape: "shape-7", |
| | | mark: "mark-5", |
| | | }, |
| | | ]; |
| | | |
| | | const selfServiceDefinitions: Array<{ |
| | | id: number; |
| | | name: string; |
| | |
| | | .map(item => ({ |
| | | id: item.id, |
| | | name: item.name, |
| | | group: "æçç³è¯·", |
| | | group: "人åèµæº", |
| | | component: item.component, |
| | | componentName: null, |
| | | developed: true, |
| | |
| | | } |
| | | |
| | | const apps = computed(() => { |
| | | const menuApps = flattenMenus(state.menus); |
| | | // 1. å端èåæ 8 å¤§æ¨¡åæ¶æï¼æ¹æ¡å¤çç®å½ï¼åååå
¬ãç³»ç»ç®¡ççï¼ä¸è¿å
¥å·¥ä½å° |
| | | const menuApps = flattenMenus(state.menus) |
| | | .map(app => { |
| | | const group = resolveModuleGroup(app); |
| | | return group ? { ...app, group } : null; |
| | | }) |
| | | .filter((app): app is WorkbenchApp => Boolean(app)); |
| | | // è¯·åæ¯ææåå·¥é½å¯ä½¿ç¨çèªå©ç³è¯·ï¼æèåæéæ¶ä½¿ç¨æ£å¼èåï¼æ²¡ææ¶è¡¥å
âæçç³è¯·âå
¥å£ã |
| | | const selfServiceApps = getSelfServiceApps(menuApps).filter( |
| | | item => item.component === "hrm/leave/index" |
| | |
| | | mark: "mark-9", |
| | | }, |
| | | ]; |
| | | return menuApps.concat(selfServiceApps, attendance); |
| | | // å·¥å婿å
ç½®å
¥å£ï¼å端èå为éèæéè½½ä½ï¼App ä¾§ææéç´æ¥å±ç¤º |
| | | const existingRoutes = new Set( |
| | | menuApps |
| | | .map(app => appRouteMap[app.component]) |
| | | .filter((route): route is string => Boolean(route)) |
| | | ); |
| | | const helperApps: WorkbenchApp[] = helperDefinitions |
| | | .filter(item => hasPermission(item.permission) && !existingRoutes.has(item.route)) |
| | | .map(item => ({ |
| | | id: item.id, |
| | | name: item.name, |
| | | group: "å·¥å婿", |
| | | component: "", |
| | | componentName: null, |
| | | developed: true, |
| | | tone: item.tone, |
| | | icon: item.icon, |
| | | shape: item.shape, |
| | | mark: item.mark, |
| | | route: item.route, |
| | | })); |
| | | return menuApps |
| | | .concat(selfServiceApps, attendance, helperApps) |
| | | .filter(app => MODULE_GROUPS.includes(app.group)); |
| | | }); |
| | | const groups = computed(() => [ |
| | | "å
¨é¨", |
| | | ...Array.from(new Set(apps.value.map(app => app.group))), |
| | | ]); |
| | | const visibleGroups = computed(() => |
| | | MODULE_GROUPS.filter(group => apps.value.some(app => app.group === group)) |
| | | ); |
| | | const groups = computed(() => ["å
¨é¨", ...visibleGroups.value]); |
| | | const filteredApps = computed(() => { |
| | | const searchValue = keyword.value.trim().toLowerCase(); |
| | | return apps.value.filter(app => { |
| | |
| | | (result[app.group] ??= []).push(app); |
| | | return result; |
| | | }, {}) |
| | | ); |
| | | // åç»æ ç¾ä¸å
容åºé½ææ¹æ¡é¡ºåºæ¸²æï¼è䏿¯æå端èåè¿åé¡ºåº |
| | | const displayGroups = computed(() => |
| | | visibleGroups.value.filter(group => groupedApps.value[group]?.length) |
| | | ); |
| | | |
| | | async function loadApps() { |
| | |
| | | } |
| | | |
| | | function openApp(app: WorkbenchApp) { |
| | | const route = appRouteMap[app.component]; |
| | | const route = app.route || appRouteMap[app.component]; |
| | | if (route) { |
| | | uni.navigateTo({ url: route }); |
| | | return; |
| | |
| | | </view> |
| | | <view v-else-if="filteredApps.length" |
| | | class="app-content"> |
| | | <view v-for="(items, group) in groupedApps" |
| | | <view v-for="group in displayGroups" |
| | | :key="group" |
| | | class="app-group"> |
| | | <view class="section-heading"> |
| | | <view><text>{{ group }}</text><text>åºç¨å
¥å£</text></view><text>{{ items.length }} 项</text> |
| | | <view><text>{{ group }}</text><text>åºç¨å
¥å£</text></view><text>{{ groupedApps[group].length }} 项</text> |
| | | </view> |
| | | <view class="app-grid"> |
| | | <view v-for="app in items" |
| | | <view v-for="app in groupedApps[group]" |
| | | :key="app.id" |
| | | :class="['app-item', { undeveloped: !app.developed }]" |
| | | @click="openApp(app)"> |