gaoluyang
5 天以前 29ee038926488983cc77b61897e4e7c30fb94c57
feat(app): 诚发五金首页与 8 大模块工作台,工单助手全流程接入

- 新增 chenfa 公司配置与专属首页(8 大模块 + 工单助手 KPI 与快捷入口)
- 工作台按计划排程/生产管控/设备管理/采购管理/仓储物流/营销管理/人力资源/工单助手固定分组
- 工单助手内置入口:上报、受理、分析、问题库、退货申请(按权限展示)
- 新增工单分析页:状态分布、问题类型、处理人工作量、签到/升级/满意度
- 工单详情支持现场签到(定位)、转派、升级、处理记录与进度时间线
- 工单上报支持扫码带出销售订单,断网时本地暂存并在联网后自动补传

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加3个文件
已修改8个文件
864 ■■■■■ 文件已修改
src/api/aftersales.ts 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/company.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/aftersales/offline-queue.ts 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/aftersales/ticket/detail.vue 127 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/aftersales/ticket/form.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/aftersales/ticket/index.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/aftersales/ticket/statistics.vue 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/home/chenfa.vue 228 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/home/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/workbench/index.vue 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/aftersales.ts
@@ -12,7 +12,7 @@
  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[];
@@ -36,6 +36,23 @@
  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;
@@ -66,6 +83,22 @@
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) }); }
src/config/company.ts
@@ -1,7 +1,7 @@
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 ä¸€è‡´ï¼Œä¾¿äºŽæ‰“日志 / ç¼“存命名空间 */
@@ -16,7 +16,7 @@
}
/**
 * å…«å¥—环境对照表。
 * ä¹å¥—环境对照表。
 * - apiBaseUrl ç»Ÿä¸€ä»¥ `/admin-api` ç»“尾(yudao åŽå°æŽ¥å£å‰ç¼€ï¼‰ã€‚
 * - webBaseUrl:只有主分支(MOM æ™ºèƒ½æ™ºé€ ï¼‰æ˜¯æ—¢æœ‰é…ç½®ï¼ˆ9501);其余按主分支(MOM æ™ºèƒ½æ™ºé€ ï¼‰â€œæŽ¥å£ç«¯å£ +1”的部署规律
 *   æŽ¨å¯¼ï¼ˆ9500 â†’ 9501)。9017 / 9027 / 9025 å®žæµ‹èƒ½æ‰“开后台,1.95.78.124:9007 ç›®å‰è¿žä¸ä¸Š
@@ -80,9 +80,17 @@
    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];
src/pages.json
@@ -1669,6 +1669,10 @@
          "style": { "navigationBarTitleText": "售后工单详情" }
        },
        {
          "path": "ticket/statistics",
          "style": { "navigationBarTitleText": "工单分析", "enablePullDownRefresh": true }
        },
        {
          "path": "return/index",
          "style": { "navigationBarTitleText": "退货申请", "enablePullDownRefresh": true }
        },
src/pages/aftersales/offline-queue.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
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 };
}
src/pages/aftersales/ticket/detail.vue
@@ -1,18 +1,29 @@
<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;
@@ -20,14 +31,15 @@
  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") {
@@ -37,19 +49,74 @@
  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>
@@ -62,12 +129,27 @@
        <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">
@@ -78,11 +160,31 @@
      </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>
@@ -90,6 +192,8 @@
    <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>
@@ -97,4 +201,9 @@
@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>
src/pages/aftersales/ticket/form.vue
@@ -9,6 +9,7 @@
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 }
@@ -91,12 +92,63 @@
  }
  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 ? "编辑售后工单" : "新增售后工单" });
@@ -115,7 +167,7 @@
  <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>
@@ -151,4 +203,6 @@
    <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>
src/pages/aftersales/ticket/index.vue
@@ -3,9 +3,11 @@
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");
@@ -44,7 +46,17 @@
  } 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>
@@ -56,6 +68,7 @@
        <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)">
@@ -71,4 +84,6 @@
    <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>
src/pages/aftersales/ticket/statistics.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,144 @@
<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>
src/pages/home/chenfa.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,228 @@
<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>
src/pages/home/index.vue
@@ -1,6 +1,7 @@
<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";
@@ -10,7 +11,8 @@
</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'" />
src/pages/workbench/index.vue
@@ -18,9 +18,23 @@
    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("");
@@ -138,6 +152,84 @@
    };
  }
  /**
   * æŠŠåŽç«¯ä¸€çº§ç›®å½•收敛到 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;
@@ -200,7 +292,7 @@
      .map(item => ({
        id: item.id,
        name: item.name,
        group: "我的申请",
        group: "人力资源",
        component: item.component,
        componentName: null,
        developed: true,
@@ -212,7 +304,13 @@
  }
  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"
@@ -235,12 +333,35 @@
              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 => {
@@ -256,6 +377,10 @@
      (result[app.group] ??= []).push(app);
      return result;
    }, {})
  );
  // åˆ†ç»„标签与内容区都按方案顺序渲染,而不是按后端菜单返回顺序
  const displayGroups = computed(() =>
    visibleGroups.value.filter(group => groupedApps.value[group]?.length)
  );
  async function loadApps() {
@@ -277,7 +402,7 @@
  }
  function openApp(app: WorkbenchApp) {
    const route = appRouteMap[app.component];
    const route = app.route || appRouteMap[app.component];
    if (route) {
      uni.navigateTo({ url: route });
      return;
@@ -339,14 +464,14 @@
      </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)">