Commit f55c6aab by pangchong

Initial commit

parents
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# public/shtml
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
pnpm-lock.yaml
components.d.ts
/src/components.d.ts
/src/auto-imports.d.ts
/dist/*
/html/*
.local
.antigravityrules
.rules
/node_modules/**
**/*.svg
**/*.sh
/public/*
/shtml/*
\ No newline at end of file
{
"singleQuote": true,
"semi": false,
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "ignore",
"endOfLine": "auto",
"trailingComma": "none",
"arrowParens": "always",
"tabWidth": 4,
"printWidth": 150
}
module.exports = {
extends: ['@commitlint/config-conventional'],
// 校验规则
rules: {
'type-enum': [2, 'always', ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'revert', 'build']],
'type-case': [0],
'type-empty': [0],
'scope-empty': [0],
'scope-case': [0],
'subject-full-stop': [0, 'never'],
'subject-case': [0, 'never'],
'header-max-length': [0, 'always', 72]
}
}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AMRO 系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "editor",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:prod": "vite --mode prod",
"dev:test": "vite --mode test",
"build": "vue-tsc -b && vite build",
"build:prod": "vue-tsc -b && vite build --mode prod",
"preview": "vite preview",
"prepare": "husky install",
"commitlint": "commitlint --config commitlint.config.cjs -e -V",
"format": "prettier --write \"./**/*.{html,vue,ts,js,json,md}\""
},
"dependencies": {
"@alova/scene-vue": "^1.6.2",
"@bpmn-io/properties-panel": "^3.40.4",
"@vicons/ionicons5": "^0.13.0",
"alova": "^3.5.0",
"bpmn-js": "^17.11.1",
"bpmn-js-properties-panel": "^5.53.0",
"camunda-bpmn-moddle": "^7.0.1",
"dayjs": "^1.11.19",
"docx-preview": "^0.3.7",
"less": "^4.5.1",
"lodash-es": "^4.18.1",
"mammoth": "^1.11.0",
"mitt": "^3.0.1",
"naive-ui": "^2.43.2",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"vfonts": "^0.0.3",
"vue": "^3.5.25",
"vue-i18n": "^11.2.8",
"vue-router": "^5.0.3",
"vuedraggable": "^4.1.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@commitlint/cli": "^19.8.0",
"@commitlint/config-conventional": "^19.8.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.14",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.27",
"husky": "^8.0.0",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}
<?xml version="1.0" encoding="utf-8"?>
<!-- ========================================================== -->
<!-- Appendix module. -->
<!-- Author: ZJX -->
<!-- Version: 1.0 -->
<!-- ========================================================== -->
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:fo="http://www.w3.org/1999/XSL/Format" version="2.0">
<xsl:template match="APPEND">
<xsl:if test="not (@CHG) or @CHG !='D'">
<xsl:choose>
<xsl:when test="//EOTK-HEADER">
<xsl:call-template name="eotkAppendixPageSet"/>
</xsl:when>
<xsl:otherwise>
<xsl:apply-templates/>
<fo:block id="lastpage"/>
</xsl:otherwise>
</xsl:choose>
</xsl:if>
</xsl:template>
<xsl:template name="eotkAppendixPageSet">
<fo:page-sequence master-reference="otk-otherpages" break-before="page" id="{generate-id()}" font-size="{$g_font_size}" font-family="{$g_font_family}">
<fo:static-content flow-name="xsl-region-before">
<fo:block xsl:use-attribute-sets="blockWrap">
<xsl:call-template name="otkFirstPageHeader"/>
</fo:block>
</fo:static-content>
<fo:static-content flow-name="xsl-region-after">
<fo:block xsl:use-attribute-sets="blockWrap">
<xsl:call-template name="otkCommonPageFooter"/>
</fo:block>
</fo:static-content>
<fo:flow flow-name="xsl-region-body">
<xsl:call-template name="appendixDisplay"/>
</fo:flow>
</fo:page-sequence>
</xsl:template>
<xsl:template name="appendixDisplay">
<fo:block xsl:use-attribute-sets="blockWrap" margin-top="4pt">
<xsl:call-template name="generateID"/>
<!--<xsl:call-template name="showRevMarker"/>-->
<xsl:apply-templates/>
</fo:block>
<fo:block id="lastpage"/>
</xsl:template>
</xsl:stylesheet>
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
exclude-result-prefixes="xs"
version="2.0">
</xsl:stylesheet>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:fo="http://www.w3.org/1999/XSL/Format" version="2.0">
<xsl:template match="revst | REVST">
<fo:block>
<fo:change-bar-begin change-bar-class="b" change-bar-style="solid" change-bar-width="2pt" change-bar-color="black" change-bar-placement="outside"/>
</fo:block>
</xsl:template>
<xsl:template match="revend | REVEND">
<fo:block>
<fo:change-bar-end change-bar-class="b" change-bar-style="solid" change-bar-width="2pt" change-bar-color="black" change-bar-placement="outside"/>
</fo:block>
</xsl:template>
</xsl:stylesheet>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet version="2.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:fo="http://www.w3.org/1999/XSL/Format">
<!--Note-->
<xsl:template match="note | NOTE">
<xsl:choose>
<xsl:when test="//LMJC-HEADER">
<fo:block space-before="5pt" space-after="3pt" color="blue" >
<fo:list-block provisional-label-separation="5pt" provisional-distance-between-starts="70pt">
<fo:list-item>
<fo:list-item-label end-indent="label-end()">
<fo:block font-weight="bold" text-decoration="underline">注意 NOTE :</fo:block>
</fo:list-item-label>
<fo:list-item-body start-indent="body-start()">
<fo:block>
<xsl:apply-templates select="*[not(self::EFFECT)]"/>
</fo:block>
</fo:list-item-body>
</fo:list-item>
</fo:list-block>
</fo:block>
</xsl:when>
<xsl:otherwise>
<fo:block space-before="5pt" space-after="3pt" color="blue" >
<xsl:variable name="needToShow">
<xsl:call-template name="showAncestorEFFECT"/>
</xsl:variable>
<xsl:if test="$needToShow='true'">
<xsl:apply-templates select="ancestor::*[child::EFFECT][1]/EFFECT"/>
</xsl:if>
<xsl:apply-templates select="EFFECT"/>
<fo:list-block provisional-label-separation="5pt" provisional-distance-between-starts="70pt">
<fo:list-item>
<fo:list-item-label end-indent="label-end()">
<fo:block font-weight="bold" text-decoration="underline">注意 NOTE :</fo:block>
</fo:list-item-label>
<fo:list-item-body start-indent="body-start()">
<fo:block>
<xsl:apply-templates select="*[not(self::EFFECT)]"/>
</fo:block>
</fo:list-item-body>
</fo:list-item>
</fo:list-block>
</fo:block>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!--warning-->
<xsl:template match="warning | WARNING">
<fo:block space-before="5pt" space-after="3pt" color="red" text-transform="uppercase">
<xsl:variable name="needToShow">
<xsl:call-template name="showAncestorEFFECT"/>
</xsl:variable>
<xsl:if test="$needToShow='true'">
<xsl:apply-templates select="ancestor::*[child::EFFECT][1]/EFFECT"/>
</xsl:if>
<xsl:apply-templates select="EFFECT"/>
<fo:list-block provisional-label-separation="5pt" provisional-distance-between-starts="82pt">
<fo:list-item>
<fo:list-item-label end-indent="label-end()">
<fo:block font-weight="bold" text-decoration="underline">
<fo:inline >警告 WARNING</fo:inline>:</fo:block>
</fo:list-item-label>
<fo:list-item-body start-indent="body-start()">
<fo:block>
<xsl:apply-templates select="*[not(self::EFFECT)]"/>
</fo:block>
</fo:list-item-body>
</fo:list-item>
</fo:list-block>
</fo:block>
</xsl:template>
<!--Caution-->
<xsl:template match="caution | CAUTION">
<fo:block space-before="5pt" space-after="3pt" color="#FF6A00">
<!-- text-transform="uppercase" -->
<xsl:variable name="needToShow">
<xsl:call-template name="showAncestorEFFECT"/>
</xsl:variable>
<xsl:if test="$needToShow='true'">
<xsl:apply-templates select="ancestor::*[child::EFFECT][1]/EFFECT"/>
</xsl:if>
<xsl:apply-templates select="EFFECT"/>
<fo:list-block provisional-label-separation="5pt" provisional-distance-between-starts="82pt">
<fo:list-item>
<fo:list-item-label end-indent="label-end()">
<fo:block font-weight="bold" text-decoration="underline"><fo:inline >警戒 CAUTION</fo:inline>:</fo:block>
</fo:list-item-label>
<fo:list-item-body start-indent="body-start()">
<fo:block>
<xsl:apply-templates select="*[not(self::EFFECT)]"/>
</fo:block>
</fo:list-item-body>
</fo:list-item>
</fo:list-block>
</fo:block>
</xsl:template>
<xsl:template match="CHGDESC|GROUP|DELETED|REGULATORY|TFMATR">
<xsl:apply-templates/>
</xsl:template>
<xsl:template match="ASSODATA">
<!-- ASSODATA隐藏不展示 -->
</xsl:template>
</xsl:stylesheet>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<!-- ========================================================== -->
<!-- Configuration for all doctypes stylesheet -->
<!-- ========================================================== -->
<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<!--水印-->
<xsl:param name="v_watermark">file:D:/sumsoars/jc/stylesheet/graphics/watermarks/</xsl:param>
<!--营运人logo-->
<xsl:param name="v_logo">file:D:/sumsoars/jc/stylesheet/graphics/logos/</xsl:param>
<!--其他图标-->
<xsl:param name="v_icon">file:D:/sumsoars/jc/stylesheet/graphics/icon/</xsl:param>
<!--图片位置-->
<xsl:variable name="graphics_dir" select="''"/>
</xsl:stylesheet>
<?xml version="1.0" encoding="UTF-8"?>
<!-- ========================================================== -->
<!-- Configuration for all doctypes stylesheet -->
<!-- ========================================================== -->
<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<!--水印-->
<xsl:param name="v_watermark">file:/mnt/ftp/tdms/stylesheet/graphics/watermarks/</xsl:param>
<!--营运人logo-->
<!--<xsl:param name="v_logo">file:/mnt/ftp/tdms/stylesheet/graphics/logos/</xsl:param>-->
<xsl:param name="v_logo">file:D:/work/workspace/git/tdms-ho/ho-amro-tdms/resources/tdms/stylesheet/graphics/logos/</xsl:param>
<!--其他图标-->
<!--<xsl:param name="v_icon">file:/mnt/ftp/tdms/stylesheet/graphics/icon/</xsl:param>-->
<xsl:param name="v_icon">file:D:/work/workspace/git/tdms-ho/ho-amro-tdms/resources/tdms/stylesheet/graphics/icon/</xsl:param>
<!--图片位置-->
<xsl:variable name="graphics_dir" select="''"/>
</xsl:stylesheet>
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="2.0"
xmlns:fo="http://www.w3.org/1999/XSL/Format"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:param name="wpDescriptor"/>
<xsl:param name="jcId"/>
<xsl:param name="logoBaseURL"/>
<xsl:param name="RouteId"/>
<xsl:param name="printScriptPath"/>
<!--
<xsl:param name="workPackageID">
<xsl:choose>
<xsl:when test="">
<xsl:value-of select=" "/>
</xsl:when>
<xsl:otherwise>
<xsl:text>N/A</xsl:text>
</xsl:otherwise>
</xsl:choose>
</xsl:param>
-->
<xsl:param name="jobCardID">
<xsl:value-of select="$jcId"/>
</xsl:param>
<!--
<xsl:param name="revDate">
<xsl:for-each select="$metaFileDoc/workpackage/job_card[@id=$jcId]">
<xsl:choose>
<xsl:when test="@revdate">
<xsl:value-of select="@revdate"/>
</xsl:when>
<xsl:otherwise>
<xsl:choose>
<xsl:when test="parent::workpackage/@revdate">
<xsl:value-of select="parent::workpackage/@revdate"/>
</xsl:when>
<xsl:otherwise>
<xsl:text>N/A</xsl:text>
</xsl:otherwise>
</xsl:choose>
</xsl:otherwise>
</xsl:choose>
</xsl:for-each>
</xsl:param>
<xsl:param name="issueDate">
<xsl:for-each select="$metaFileDoc/workpackage/job_card[@id=$jcId]">
<xsl:choose>
<xsl:when test="@issuedate">
<xsl:value-of select="@issuedate"/>
</xsl:when>
<xsl:otherwise>
<xsl:choose>
<xsl:when test="parent::workpackage/@issuedate">
<xsl:value-of select="parent::workpackage/@issuedate"/>
</xsl:when>
<xsl:otherwise>
<xsl:text>N/A</xsl:text>
</xsl:otherwise>
</xsl:choose>
</xsl:otherwise>
</xsl:choose>
</xsl:for-each>
</xsl:param>
<xsl:param name="layout">
<xsl:for-each select="$metaFileDoc/workpackage/job_card[@id=$jcId]">
<xsl:choose>
<xsl:when test="@layout">
<xsl:value-of select="@layout"/>
</xsl:when>
<xsl:otherwise>
<xsl:choose>
<xsl:when test="$metaFileDoc/workpackage/@layout">
<xsl:value-of select="$metaFileDoc/workpackage/@layout"/>
</xsl:when>
<xsl:otherwise>
<xsl:text>N/A</xsl:text>
</xsl:otherwise>
</xsl:choose>
</xsl:otherwise>
</xsl:choose>
</xsl:for-each>
</xsl:param>
<xsl:param name="tailNumber">
<xsl:for-each select="$metaFileDoc/workpackage/job_card[@id=$jcId]">
<xsl:choose>
<xsl:when test="@tailnumber">
<xsl:value-of select="@tailnumber"/>
</xsl:when>
<xsl:otherwise>
<xsl:choose>
<xsl:when test="parent::workpackage/@tailnumber">
<xsl:value-of select="parent::workpackage/@tailnumber"/>
</xsl:when>
<xsl:otherwise>
<xsl:text>N/A</xsl:text>
</xsl:otherwise>
</xsl:choose>
</xsl:otherwise>
</xsl:choose>
</xsl:for-each>
</xsl:param>
<xsl:param name="model">
<xsl:for-each select="$metaFileDoc/workpackage/job_card[@id=$jcId]">
<xsl:choose>
<xsl:when test="@model">
<xsl:value-of select="/@model"/>
</xsl:when>
<xsl:otherwise>
<xsl:choose>
<xsl:when test="parent::workpackage/@model">
<xsl:value-of select="parent::workpackage/@model"/>
</xsl:when>
<xsl:otherwise>
<xsl:text>N/A</xsl:text>
</xsl:otherwise>
</xsl:choose>
</xsl:otherwise>
</xsl:choose>
</xsl:for-each>
</xsl:param>
-->
<!-- 调用样式传入参数 -->
<xsl:param name="dataImagesURL"></xsl:param>
<xsl:param name="action">preview</xsl:param>
<!--'standalone'、'archive'、'template'、'preview' 根据这个显示不同的样式或数据 -->
<xsl:param name="show_eff">Y</xsl:param>
<!-- 是否显示适用性,对于单机卡可能不显示适用性 -->
<xsl:param name="viewer_url"></xsl:param>
<!-- 查看手册或图片的链接,默认为生产环境地址 -->
<xsl:param name="show_ex_st">Y</xsl:param>
<!-- 是否显示附加签字点 -->
<xsl:param name="att_name"/>
<xsl:param name="att_prefix">S</xsl:param>
<!--部件卡为P,定检卡为S-->
<xsl:param name="att_suffix"/>
<xsl:param name="showNaBlow">N</xsl:param>
<xsl:param name="naTitle">N/A</xsl:param>
<xsl:param name="showNaAbove">Y</xsl:param>
<xsl:param name="tot_title"/>
<!-- 为空则EOTK,不为空则TOTK -->
<xsl:param name="tot_titlec"/>
<xsl:param name="barcode"/>
<!-- 条形码 -->
<xsl:param name="inspLevel"/>
<!-- <xsl:param name="watermark"/> -->
<!-- 水印 -->
<!--添加获取customer code 参数-->
<xsl:param name="customerCode">UEA</xsl:param>
<xsl:param name="model">
<xsl:if test="//SMJC-HEADER or //TCJC-HEADER or //NRCJC-HEADER or //QECJC-HEADER or //EOTK-HEADER or //LMJC-HEADER">
<xsl:value-of select="//JC-AC"/>
</xsl:if>
</xsl:param>
</xsl:stylesheet>
<?xml version='1.0'?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
version="2.0">
<xsl:param name="default.table.width" select="''"/>
<xsl:param name="table.border.color" select="'black'"/>
<xsl:param name="table.border.style" select="'solid'"/>
<xsl:param name="table.border.thickness" select="'0.5pt'"/>
<xsl:param name="table.cell.padding.amount" select="'2pt'"/>
<xsl:param name="inhibit-default-colwidth-emission" select='1'/>
</xsl:stylesheet>
<?xml version='1.0'?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:fo="http://www.w3.org/1999/XSL/Format"
xmlns:se="http://www.syntext.com/XSL/Format-1.0"
version="2.0">
<!--======================================================-->
<!-- set the global variable of customer name -->
<!--======================================================-->
<xsl:variable name="g_customer_name" select="''"/>
<!--======================================================-->
<!-- set the global variable of manual name -->
<!--======================================================-->
<xsl:variable name="g_manual_name" select="''"/>
<!-- <xsl:variable name="metaFilePath" select="concat($printScriptPath,$jcId,'_Param.xml')"/>
<xsl:variable name="metaFileDoc" select="document($metaFilePath)"/> -->
<xsl:variable name="lowerCase">abcdefghijklmnopqrstuvwxyz</xsl:variable>
<xsl:variable name="upperCase">ABCDEFGHIJKLMNOPQRSTUVWXYZ</xsl:variable>
<!-- <xsl:variable name="metaFilePathWP" select="''"/>
<xsl:variable name="compatible" select="false()"/> -->
</xsl:stylesheet>
\ No newline at end of file
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
import{r as e,i as t,g as r,h as n}from"./Button-ByITIhW2.js";import{j as o,b3 as a,f as i,g as s,u as l,h as p,b4 as d,v as g,b5 as c,az as C}from"./index-BGNM-WBG.js";import{u as m}from"./use-rtl-iN3przWb.js";const u=e("error",()=>o("svg",{viewBox:"0 0 48 48",version:"1.1",xmlns:"http://www.w3.org/2000/svg"},o("g",{stroke:"none","stroke-width":"1","fill-rule":"evenodd"},o("g",{"fill-rule":"nonzero"},o("path",{d:"M24,4 C35.045695,4 44,12.954305 44,24 C44,35.045695 35.045695,44 24,44 C12.954305,44 4,35.045695 4,24 C4,12.954305 12.954305,4 24,4 Z M17.8838835,16.1161165 L17.7823881,16.0249942 C17.3266086,15.6583353 16.6733914,15.6583353 16.2176119,16.0249942 L16.1161165,16.1161165 L16.0249942,16.2176119 C15.6583353,16.6733914 15.6583353,17.3266086 16.0249942,17.7823881 L16.1161165,17.8838835 L22.233,24 L16.1161165,30.1161165 L16.0249942,30.2176119 C15.6583353,30.6733914 15.6583353,31.3266086 16.0249942,31.7823881 L16.1161165,31.8838835 L16.2176119,31.9750058 C16.6733914,32.3416647 17.3266086,32.3416647 17.7823881,31.9750058 L17.8838835,31.8838835 L24,25.767 L30.1161165,31.8838835 L30.2176119,31.9750058 C30.6733914,32.3416647 31.3266086,32.3416647 31.7823881,31.9750058 L31.8838835,31.8838835 L31.9750058,31.7823881 C32.3416647,31.3266086 32.3416647,30.6733914 31.9750058,30.2176119 L31.8838835,30.1161165 L25.767,24 L31.8838835,17.8838835 L31.9750058,17.7823881 C32.3416647,17.3266086 32.3416647,16.6733914 31.9750058,16.2176119 L31.8838835,16.1161165 L31.7823881,16.0249942 C31.3266086,15.6583353 30.6733914,15.6583353 30.2176119,16.0249942 L30.1161165,16.1161165 L24,22.233 L17.8838835,16.1161165 L17.7823881,16.0249942 L17.8838835,16.1161165 Z"}))))),f=e("info",()=>o("svg",{viewBox:"0 0 28 28",version:"1.1",xmlns:"http://www.w3.org/2000/svg"},o("g",{stroke:"none","stroke-width":"1","fill-rule":"evenodd"},o("g",{"fill-rule":"nonzero"},o("path",{d:"M14,2 C20.6274,2 26,7.37258 26,14 C26,20.6274 20.6274,26 14,26 C7.37258,26 2,20.6274 2,14 C2,7.37258 7.37258,2 14,2 Z M14,11 C13.4477,11 13,11.4477 13,12 L13,12 L13,20 C13,20.5523 13.4477,21 14,21 C14.5523,21 15,20.5523 15,20 L15,20 L15,12 C15,11.4477 14.5523,11 14,11 Z M14,6.75 C13.3096,6.75 12.75,7.30964 12.75,8 C12.75,8.69036 13.3096,9.25 14,9.25 C14.6904,9.25 15.25,8.69036 15.25,8 C15.25,7.30964 14.6904,6.75 14,6.75 Z"}))))),L=e("success",()=>o("svg",{viewBox:"0 0 48 48",version:"1.1",xmlns:"http://www.w3.org/2000/svg"},o("g",{stroke:"none","stroke-width":"1","fill-rule":"evenodd"},o("g",{"fill-rule":"nonzero"},o("path",{d:"M24,4 C35.045695,4 44,12.954305 44,24 C44,35.045695 35.045695,44 24,44 C12.954305,44 4,35.045695 4,24 C4,12.954305 12.954305,4 24,4 Z M32.6338835,17.6161165 C32.1782718,17.1605048 31.4584514,17.1301307 30.9676119,17.5249942 L30.8661165,17.6161165 L20.75,27.732233 L17.1338835,24.1161165 C16.6457281,23.6279612 15.8542719,23.6279612 15.3661165,24.1161165 C14.9105048,24.5717282 14.8801307,25.2915486 15.2749942,25.7823881 L15.3661165,25.8838835 L19.8661165,30.3838835 C20.3217282,30.8394952 21.0415486,30.8698693 21.5323881,30.4750058 L21.6338835,30.3838835 L32.6338835,19.3838835 C33.1220388,18.8957281 33.1220388,18.1042719 32.6338835,17.6161165 Z"}))))),v=e("warning",()=>o("svg",{viewBox:"0 0 24 24",version:"1.1",xmlns:"http://www.w3.org/2000/svg"},o("g",{stroke:"none","stroke-width":"1","fill-rule":"evenodd"},o("g",{"fill-rule":"nonzero"},o("path",{d:"M12,2 C17.523,2 22,6.478 22,12 C22,17.522 17.523,22 12,22 C6.477,22 2,17.522 2,12 C2,6.478 6.477,2 12,2 Z M12.0018002,15.0037242 C11.450254,15.0037242 11.0031376,15.4508407 11.0031376,16.0023869 C11.0031376,16.553933 11.450254,17.0010495 12.0018002,17.0010495 C12.5533463,17.0010495 13.0004628,16.553933 13.0004628,16.0023869 C13.0004628,15.4508407 12.5533463,15.0037242 12.0018002,15.0037242 Z M11.99964,7 C11.4868042,7.00018474 11.0642719,7.38637706 11.0066858,7.8837365 L11,8.00036004 L11.0018003,13.0012393 L11.00857,13.117858 C11.0665141,13.6151758 11.4893244,14.0010638 12.0021602,14.0008793 C12.514996,14.0006946 12.9375283,13.6145023 12.9951144,13.1171428 L13.0018002,13.0005193 L13,7.99964009 L12.9932303,7.8830214 C12.9352861,7.38570354 12.5124758,6.99981552 11.99964,7 Z"})))));const w={name:"Space",self:function(){return a}};let h;function x(){if(!t)return!0;if(void 0===h){const e=document.createElement("div");e.style.display="flex",e.style.flexDirection="column",e.style.rowGap="1px",e.appendChild(document.createElement("div")),e.appendChild(document.createElement("div")),document.body.appendChild(e);const t=1===e.scrollHeight;return document.body.removeChild(e),h=t}return h}const y=i({name:"Space",props:Object.assign(Object.assign({},l.props),{align:String,justify:{type:String,default:"start"},inline:Boolean,vertical:Boolean,reverse:Boolean,size:{type:[String,Number,Array],default:"medium"},wrapItem:{type:Boolean,default:!0},itemClass:String,itemStyle:[String,Object],wrap:{type:Boolean,default:!0},internalUseGap:{type:Boolean,default:void 0}}),setup(e){const{mergedClsPrefixRef:t,mergedRtlRef:r}=s(e),n=l("Space","-space",void 0,w,e,t),o=m("Space",r,t);return{useGap:x(),rtlEnabled:o,mergedClsPrefix:t,margin:p(()=>{const{size:t}=e;if(Array.isArray(t))return{horizontal:t[0],vertical:t[1]};if("number"==typeof t)return{horizontal:t,vertical:t};const{self:{[g("gap",t)]:r}}=n.value,{row:o,col:a}=c(r);return{horizontal:C(a),vertical:C(o)}})}},render(){const{vertical:e,reverse:t,align:a,inline:i,justify:s,itemClass:l,itemStyle:p,margin:g,wrap:c,mergedClsPrefix:C,rtlEnabled:m,useGap:u,wrapItem:f,internalUseGap:L}=this,v=r(n(this),!1);if(!v.length)return null;const w=`${g.horizontal}px`,h=g.horizontal/2+"px",x=`${g.vertical}px`,y=g.vertical/2+"px",b=v.length-1,B=s.startsWith("space-");return o("div",{role:"none",class:[`${C}-space`,m&&`${C}-space--rtl`],style:{display:i?"inline-flex":"flex",flexDirection:e&&!t?"column":e&&t?"column-reverse":!e&&t?"row-reverse":"row",justifyContent:["start","end"].includes(s)?`flex-${s}`:s,flexWrap:!c||e?"nowrap":"wrap",marginTop:u||e?"":`-${y}`,marginBottom:u||e?"":`-${y}`,alignItems:a,gap:u?`${g.vertical}px ${g.horizontal}px`:""}},f||!u&&!L?v.map((t,r)=>t.type===d?t:o("div",{role:"none",class:l,style:[p,{maxWidth:"100%"},u?"":e?{marginBottom:r!==b?x:""}:m?{marginLeft:B?"space-between"===s&&r===b?"":h:r!==b?w:"",marginRight:B?"space-between"===s&&0===r?"":h:"",paddingTop:y,paddingBottom:y}:{marginRight:B?"space-between"===s&&r===b?"":h:r!==b?w:"",marginLeft:B?"space-between"===s&&0===r?"":h:"",paddingTop:y,paddingBottom:y}]},t)):v)}});export{u as E,f as I,L as S,v as W,y as _};
const o=(o,t)=>{const c=o.__vccOpts||o;for(const[s,n]of t)c[s]=n;return c};export{o as _};
This source diff could not be displayed because it is too large. You can view the blob instead.
import{j as t,d as e,q as s,f as n,g as l,u as i,h as r,i as o,t as c,v as a,o as u,x as f,n as d,w as p,y as h,z as v}from"./index-BGNM-WBG.js";import{N as x,_ as g}from"./Button-ByITIhW2.js";import{I as z,S as m,W as w,E as y,_ as C}from"./Space-BiFpHGv7.js";import"./use-rtl-iN3przWb.js";const B=e("result","\n color: var(--n-text-color);\n line-height: var(--n-line-height);\n font-size: var(--n-font-size);\n transition:\n color .3s var(--n-bezier);\n",[e("result-icon","\n display: flex;\n justify-content: center;\n transition: color .3s var(--n-bezier);\n ",[s("status-image","\n font-size: var(--n-icon-size);\n width: 1em;\n height: 1em;\n "),e("base-icon","\n color: var(--n-icon-color);\n font-size: var(--n-icon-size);\n ")]),e("result-content",{marginTop:"24px"}),e("result-footer","\n margin-top: 24px;\n text-align: center;\n "),e("result-header",[s("title","\n margin-top: 16px;\n font-weight: var(--n-title-font-weight);\n transition: color .3s var(--n-bezier);\n text-align: center;\n color: var(--n-title-text-color);\n font-size: var(--n-title-font-size);\n "),s("description","\n margin-top: 4px;\n text-align: center;\n font-size: var(--n-font-size);\n ")])]),F={403:function(){return t("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 36 36"},t("path",{fill:"#EF9645",d:"M15.5 2.965c1.381 0 2.5 1.119 2.5 2.5v.005L20.5.465c1.381 0 2.5 1.119 2.5 2.5V4.25l2.5-1.535c1.381 0 2.5 1.119 2.5 2.5V8.75L29 18H15.458L15.5 2.965z"}),t("path",{fill:"#FFDC5D",d:"M4.625 16.219c1.381-.611 3.354.208 4.75 2.188.917 1.3 1.187 3.151 2.391 3.344.46.073 1.234-.313 1.234-1.397V4.5s0-2 2-2 2 2 2 2v11.633c0-.029 1-.064 1-.082V2s0-2 2-2 2 2 2 2v14.053c0 .017 1 .041 1 .069V4.25s0-2 2-2 2 2 2 2v12.638c0 .118 1 .251 1 .398V8.75s0-2 2-2 2 2 2 2V24c0 6.627-5.373 12-12 12-4.775 0-8.06-2.598-9.896-5.292C8.547 28.423 8.096 26.051 8 25.334c0 0-.123-1.479-1.156-2.865-1.469-1.969-2.5-3.156-3.125-3.866-.317-.359-.625-1.707.906-2.384z"}))},404:function(){return t("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 36 36"},t("circle",{fill:"#FFCB4C",cx:"18",cy:"17.018",r:"17"}),t("path",{fill:"#65471B",d:"M14.524 21.036c-.145-.116-.258-.274-.312-.464-.134-.46.13-.918.59-1.021 4.528-1.021 7.577 1.363 7.706 1.465.384.306.459.845.173 1.205-.286.358-.828.401-1.211.097-.11-.084-2.523-1.923-6.182-1.098-.274.061-.554-.016-.764-.184z"}),t("ellipse",{fill:"#65471B",cx:"13.119",cy:"11.174",rx:"2.125",ry:"2.656"}),t("ellipse",{fill:"#65471B",cx:"24.375",cy:"12.236",rx:"2.125",ry:"2.656"}),t("path",{fill:"#F19020",d:"M17.276 35.149s1.265-.411 1.429-1.352c.173-.972-.624-1.167-.624-1.167s1.041-.208 1.172-1.376c.123-1.101-.861-1.363-.861-1.363s.97-.4 1.016-1.539c.038-.959-.995-1.428-.995-1.428s5.038-1.221 5.556-1.341c.516-.12 1.32-.615 1.069-1.694-.249-1.08-1.204-1.118-1.697-1.003-.494.115-6.744 1.566-8.9 2.068l-1.439.334c-.54.127-.785-.11-.404-.512.508-.536.833-1.129.946-2.113.119-1.035-.232-2.313-.433-2.809-.374-.921-1.005-1.649-1.734-1.899-1.137-.39-1.945.321-1.542 1.561.604 1.854.208 3.375-.833 4.293-2.449 2.157-3.588 3.695-2.83 6.973.828 3.575 4.377 5.876 7.952 5.048l3.152-.681z"}),t("path",{fill:"#65471B",d:"M9.296 6.351c-.164-.088-.303-.224-.391-.399-.216-.428-.04-.927.393-1.112 4.266-1.831 7.699-.043 7.843.034.433.231.608.747.391 1.154-.216.405-.74.546-1.173.318-.123-.063-2.832-1.432-6.278.047-.257.109-.547.085-.785-.042zm12.135 3.75c-.156-.098-.286-.243-.362-.424-.187-.442.023-.927.468-1.084 4.381-1.536 7.685.48 7.823.567.415.26.555.787.312 1.178-.242.39-.776.495-1.191.238-.12-.072-2.727-1.621-6.267-.379-.266.091-.553.046-.783-.096z"}))},418:function(){return t("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 36 36"},t("ellipse",{fill:"#292F33",cx:"18",cy:"26",rx:"18",ry:"10"}),t("ellipse",{fill:"#66757F",cx:"18",cy:"24",rx:"18",ry:"10"}),t("path",{fill:"#E1E8ED",d:"M18 31C3.042 31 1 16 1 12h34c0 2-1.958 19-17 19z"}),t("path",{fill:"#77B255",d:"M35 12.056c0 5.216-7.611 9.444-17 9.444S1 17.271 1 12.056C1 6.84 8.611 3.611 18 3.611s17 3.229 17 8.445z"}),t("ellipse",{fill:"#A6D388",cx:"18",cy:"13",rx:"15",ry:"7"}),t("path",{d:"M21 17c-.256 0-.512-.098-.707-.293-2.337-2.337-2.376-4.885-.125-8.262.739-1.109.9-2.246.478-3.377-.461-1.236-1.438-1.996-1.731-2.077-.553 0-.958-.443-.958-.996 0-.552.491-.995 1.043-.995.997 0 2.395 1.153 3.183 2.625 1.034 1.933.91 4.039-.351 5.929-1.961 2.942-1.531 4.332-.125 5.738.391.391.391 1.023 0 1.414-.195.196-.451.294-.707.294zm-6-2c-.256 0-.512-.098-.707-.293-2.337-2.337-2.376-4.885-.125-8.262.727-1.091.893-2.083.494-2.947-.444-.961-1.431-1.469-1.684-1.499-.552 0-.989-.447-.989-1 0-.552.458-1 1.011-1 .997 0 2.585.974 3.36 2.423.481.899 1.052 2.761-.528 5.131-1.961 2.942-1.531 4.332-.125 5.738.391.391.391 1.023 0 1.414-.195.197-.451.295-.707.295z",fill:"#5C913B"}))},500:function(){return t("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 36 36"},t("path",{fill:"#FFCC4D",d:"M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"}),t("ellipse",{fill:"#664500",cx:"18",cy:"27",rx:"5",ry:"6"}),t("path",{fill:"#664500",d:"M5.999 11c-.208 0-.419-.065-.599-.2-.442-.331-.531-.958-.2-1.4C8.462 5.05 12.816 5 13 5c.552 0 1 .448 1 1 0 .551-.445.998-.996 1-.155.002-3.568.086-6.204 3.6-.196.262-.497.4-.801.4zm24.002 0c-.305 0-.604-.138-.801-.4-2.64-3.521-6.061-3.598-6.206-3.6-.55-.006-.994-.456-.991-1.005C22.006 5.444 22.45 5 23 5c.184 0 4.537.05 7.8 4.4.332.442.242 1.069-.2 1.4-.18.135-.39.2-.599.2zm-16.087 4.5l1.793-1.793c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0L12.5 14.086l-1.793-1.793c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l1.793 1.793-1.793 1.793c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l1.793-1.793 1.793 1.793c.195.195.451.293.707.293s.512-.098.707-.293c.391-.391.391-1.023 0-1.414L13.914 15.5zm11 0l1.793-1.793c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0L23.5 14.086l-1.793-1.793c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l1.793 1.793-1.793 1.793c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l1.793-1.793 1.793 1.793c.195.195.451.293.707.293s.512-.098.707-.293c.391-.391.391-1.023 0-1.414L24.914 15.5z"}))},info:()=>t(z,null),success:()=>t(m,null),warning:()=>t(w,null),error:()=>t(y,null)},b=n({name:"Result",props:Object.assign(Object.assign({},i.props),{size:{type:String,default:"medium"},status:{type:String,default:"info"},title:String,description:String}),slots:Object,setup(t){const{mergedClsPrefixRef:e,inlineThemeDisabled:s}=l(t),n=i("Result","-result",B,c,t,e),u=r(()=>{const{size:e,status:s}=t,{common:{cubicBezierEaseInOut:l},self:{textColor:i,lineHeight:r,titleTextColor:o,titleFontWeight:c,[a("iconColor",s)]:u,[a("fontSize",e)]:f,[a("titleFontSize",e)]:d,[a("iconSize",e)]:p}}=n.value;return{"--n-bezier":l,"--n-font-size":f,"--n-icon-size":p,"--n-line-height":r,"--n-text-color":i,"--n-title-font-size":d,"--n-title-font-weight":c,"--n-title-text-color":o,"--n-icon-color":u||""}}),f=s?o("result",r(()=>{const{size:e,status:s}=t;let n="";return e&&(n+=e[0]),s&&(n+=s[0]),n}),u,t):void 0;return{mergedClsPrefix:e,cssVars:s?void 0:u,themeClass:null==f?void 0:f.themeClass,onRender:null==f?void 0:f.onRender}},render(){var e;const{status:s,$slots:n,mergedClsPrefix:l,onRender:i}=this;return null==i||i(),t("div",{class:[`${l}-result`,this.themeClass],style:this.cssVars},t("div",{class:`${l}-result-icon`},(null===(e=n.icon)||void 0===e?void 0:e.call(n))||t(x,{clsPrefix:l},{default:()=>F[s]()})),t("div",{class:`${l}-result-header`},this.title?t("div",{class:`${l}-result-header__title`},this.title):null,this.description?t("div",{class:`${l}-result-header__description`},this.description):null),n.default&&t("div",{class:`${l}-result-content`},n),n.footer&&t("div",{class:`${l}-result-footer`},n.footer()))}}),_={class:"flex justify-center items-center h-full"},j=n({__name:"index",setup(t){const e=v(),s=()=>{e.push("/")};return(t,e)=>{const n=g,l=C,i=b;return u(),f("div",_,[d(i,{status:"404",title:"页面不存在"},{footer:p(()=>[d(l,null,{default:p(()=>[d(n,{type:"primary",onClick:s},{default:p(()=>[h("返回")]),_:1})]),_:1})]),_:1})])}}});export{j as default};
import{_ as o}from"./_plugin-vue_export-helper-BCo6x5W8.js";import{c as e,a as r,s as t,b as l,d as n,e as s,f as a,u as i,r as d,g as c,p as u,h as b,i as h,j as v,k as f,l as C,o as p,m,w as g,n as x}from"./index-BGNM-WBG.js";import{u as y,N as S}from"./Scrollbar-DmIJvlAu.js";import"./use-rtl-iN3przWb.js";const B=e({name:"Layout",common:r,peers:{Scrollbar:t},self:function(o){const{baseColor:e,textColor2:r,bodyColor:t,cardColor:n,dividerColor:s,actionColor:a,scrollbarColor:i,scrollbarColorHover:d,invertedColor:c}=o;return{textColor:r,textColorInverted:"#FFF",color:t,colorEmbedded:a,headerColor:n,headerColorInverted:c,footerColor:a,footerColorInverted:c,headerBorderColor:s,headerBorderColorInverted:c,footerBorderColor:s,footerBorderColorInverted:c,siderBorderColor:s,siderBorderColorInverted:c,siderColor:n,siderColorInverted:c,siderToggleButtonBorder:`1px solid ${s}`,siderToggleButtonColor:e,siderToggleButtonIconColor:r,siderToggleButtonIconColorInverted:r,siderToggleBarColor:l(t,i),siderToggleBarColorHover:l(t,d),__invertScrollbar:"true"}}}),z={type:String,default:"static"},I=n("layout","\n color: var(--n-text-color);\n background-color: var(--n-color);\n box-sizing: border-box;\n position: relative;\n z-index: auto;\n flex: auto;\n overflow: hidden;\n transition:\n box-shadow .3s var(--n-bezier),\n background-color .3s var(--n-bezier),\n color .3s var(--n-bezier);\n",[n("layout-scroll-container","\n overflow-x: hidden;\n box-sizing: border-box;\n height: 100%;\n "),s("absolute-positioned","\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n bottom: 0;\n ")]),T={embedded:Boolean,position:z,nativeScrollbar:{type:Boolean,default:!0},scrollbarProps:Object,onScroll:Function,contentClass:String,contentStyle:{type:[String,Object],default:""},hasSider:Boolean,siderPlacement:{type:String,default:"left"}},$=f("n-layout");function R(o){return a({name:o?"LayoutContent":"Layout",props:Object.assign(Object.assign({},i.props),T),setup(o){const e=d(null),r=d(null),{mergedClsPrefixRef:t,inlineThemeDisabled:l}=c(o),n=i("Layout","-layout",I,B,o,t);u($,o);let s=0,a=0;y(()=>{if(o.nativeScrollbar){const o=e.value;o&&(o.scrollTop=a,o.scrollLeft=s)}});const v={scrollTo:function(t,l){if(o.nativeScrollbar){const{value:o}=e;o&&(void 0===l?o.scrollTo(t):o.scrollTo(t,l))}else{const{value:o}=r;o&&o.scrollTo(t,l)}}},f=b(()=>{const{common:{cubicBezierEaseInOut:e},self:r}=n.value;return{"--n-bezier":e,"--n-color":o.embedded?r.colorEmbedded:r.color,"--n-text-color":r.textColor}}),C=l?h("layout",b(()=>o.embedded?"e":""),f,o):void 0;return Object.assign({mergedClsPrefix:t,scrollableElRef:e,scrollbarInstRef:r,hasSiderStyle:{display:"flex",flexWrap:"nowrap",width:"100%",flexDirection:"row"},mergedTheme:n,handleNativeElScroll:e=>{var r;const t=e.target;s=t.scrollLeft,a=t.scrollTop,null===(r=o.onScroll)||void 0===r||r.call(o,e)},cssVars:l?void 0:f,themeClass:null==C?void 0:C.themeClass,onRender:null==C?void 0:C.onRender},v)},render(){var e;const{mergedClsPrefix:r,hasSider:t}=this;null===(e=this.onRender)||void 0===e||e.call(this);const l=t?this.hasSiderStyle:void 0,n=[this.themeClass,o&&`${r}-layout-content`,`${r}-layout`,`${r}-layout--${this.position}-positioned`];return v("div",{class:n,style:this.cssVars},this.nativeScrollbar?v("div",{ref:"scrollableElRef",class:[`${r}-layout-scroll-container`,this.contentClass],style:[this.contentStyle,l],onScroll:this.handleNativeElScroll},this.$slots):v(S,Object.assign({},this.scrollbarProps,{onScroll:this.onScroll,ref:"scrollbarInstRef",theme:this.mergedTheme.peers.Scrollbar,themeOverrides:this.mergedTheme.peerOverrides.Scrollbar,contentClass:this.contentClass,contentStyle:[this.contentStyle,l]}),this.$slots))}})}const j=R(!1),O=R(!0),w=n("layout-footer","\n transition:\n box-shadow .3s var(--n-bezier),\n color .3s var(--n-bezier),\n background-color .3s var(--n-bezier),\n border-color .3s var(--n-bezier);\n color: var(--n-text-color);\n background-color: var(--n-color);\n box-sizing: border-box;\n",[s("absolute-positioned","\n position: absolute;\n left: 0;\n right: 0;\n bottom: 0;\n "),s("bordered","\n border-top: solid 1px var(--n-border-color);\n ")]),P=Object.assign(Object.assign({},i.props),{inverted:Boolean,position:z,bordered:Boolean}),L=a({name:"LayoutFooter",props:P,setup(o){const{mergedClsPrefixRef:e,inlineThemeDisabled:r}=c(o),t=i("Layout","-layout-footer",w,B,o,e),l=b(()=>{const{common:{cubicBezierEaseInOut:e},self:r}=t.value,l={"--n-bezier":e};return o.inverted?(l["--n-color"]=r.footerColorInverted,l["--n-text-color"]=r.textColorInverted,l["--n-border-color"]=r.footerBorderColorInverted):(l["--n-color"]=r.footerColor,l["--n-text-color"]=r.textColor,l["--n-border-color"]=r.footerBorderColor),l}),n=r?h("layout-footer",b(()=>o.inverted?"a":"b"),l,o):void 0;return{mergedClsPrefix:e,cssVars:r?void 0:l,themeClass:null==n?void 0:n.themeClass,onRender:null==n?void 0:n.onRender}},render(){var o;const{mergedClsPrefix:e}=this;return null===(o=this.onRender)||void 0===o||o.call(this),v("div",{class:[`${e}-layout-footer`,this.themeClass,this.position&&`${e}-layout-footer--${this.position}-positioned`,this.bordered&&`${e}-layout-footer--bordered`],style:this.cssVars},this.$slots)}}),E=n("layout-header","\n transition:\n color .3s var(--n-bezier),\n background-color .3s var(--n-bezier),\n box-shadow .3s var(--n-bezier),\n border-color .3s var(--n-bezier);\n box-sizing: border-box;\n width: 100%;\n background-color: var(--n-color);\n color: var(--n-text-color);\n",[s("absolute-positioned","\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n "),s("bordered","\n border-bottom: solid 1px var(--n-border-color);\n ")]),k={position:z,inverted:Boolean,bordered:{type:Boolean,default:!1}},_=a({name:"LayoutHeader",props:Object.assign(Object.assign({},i.props),k),setup(o){const{mergedClsPrefixRef:e,inlineThemeDisabled:r}=c(o),t=i("Layout","-layout-header",E,B,o,e),l=b(()=>{const{common:{cubicBezierEaseInOut:e},self:r}=t.value,l={"--n-bezier":e};return o.inverted?(l["--n-color"]=r.headerColorInverted,l["--n-text-color"]=r.textColorInverted,l["--n-border-color"]=r.headerBorderColorInverted):(l["--n-color"]=r.headerColor,l["--n-text-color"]=r.textColor,l["--n-border-color"]=r.headerBorderColor),l}),n=r?h("layout-header",b(()=>o.inverted?"a":"b"),l,o):void 0;return{mergedClsPrefix:e,cssVars:r?void 0:l,themeClass:null==n?void 0:n.themeClass,onRender:null==n?void 0:n.onRender}},render(){var o;const{mergedClsPrefix:e}=this;return null===(o=this.onRender)||void 0===o||o.call(this),v("div",{class:[`${e}-layout-header`,this.themeClass,this.position&&`${e}-layout-header--${this.position}-positioned`,this.bordered&&`${e}-layout-header--bordered`],style:this.cssVars},this.$slots)}});const V=o({},[["render",function(o,e){const r=_,t=C("router-view"),l=O,n=L,s=j;return p(),m(s,{class:"h-full overflow-hidden"},{default:g(()=>[x(r),x(l,{"content-class":"flex flex-col flex-auto overflow-x-auto bg-codeColor",class:"h-full flex flex-col"},{default:g(()=>[x(t)]),_:1}),x(n)]),_:1})}]]);export{V as default};
This source diff could not be displayed because it is too large. You can view the blob instead.
import{_ as r}from"./_plugin-vue_export-helper-BCo6x5W8.js";import{o as e,x as o}from"./index-BGNM-WBG.js";const t=r({},[["render",function(r,t){return e(),o("div")}]]);export{t as default};
import{f as a,A as e,h as l,o as t,x as s,n as r,w as o,y as n,B as u,F as i,j as m,C as c,D as p,E as d}from"./index-BGNM-WBG.js";import{_ as h,a as f}from"./DataTable-CEJURamz.js";import{_ as b}from"./Button-ByITIhW2.js";import"./use-rtl-iN3przWb.js";import"./Scrollbar-DmIJvlAu.js";const y=a({__name:"theme",setup(a){const y=e(),j=()=>{y.changeTheme()},k=[{title:"变量名",key:"label"},{title:"变量值",key:"value"},{title:"颜色",key:"color",render:a=>m("div",{style:{width:"30px",height:"30px",backgroundColor:a.value}})},{title:"值",key:"value",render:a=>m("span",{},y.theme?c[a.label]:p[a.label])}],x=l(()=>Object.entries(d.colors).map(([a,e])=>({label:a,value:e})));return(a,e)=>{const l=b,m=h,c=f;return t(),s(i,null,[r(m,null,{default:o(()=>[r(l,{type:"primary",onClick:j},{default:o(()=>[n("主题切换")]),_:1})]),_:1}),r(c,{columns:k,data:u(x),"flex-height":"",class:"h-full"},null,8,["data"])],64)}}});export{y as default};
import{b_ as t,b7 as n,h as r,J as s,bC as e,H as a,a4 as o,bW as u}from"./index-BGNM-WBG.js";function i(i,l,c){if(!l)return;const f=n(),d=r(()=>{const{value:t}=l;if(!t)return;const n=t[i];return n||void 0}),v=s(e,null),b=()=>{o(()=>{const{value:n}=c,r=`${n}${i}Rtl`;if(function(n,r){if(void 0===n)return!1;if(r){const{context:{ids:t}}=r;return t.has(n)}return null!==t(n)}(r,f))return;const{value:s}=d;s&&s.style.mount({id:r,head:!0,anchorMetaName:u,props:{bPrefix:n?`.${n}-`:void 0},ssr:f,parent:null==v?void 0:v.styleMountTarget})})};return f?b():a(b),d}export{i as u};
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1724118561720" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4707" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M922.85749971 610.25000029C845.74999971 509.75 730.62500029 406.54999971 698.89249971 381.42500029 676.25 363.5 654.46250029 354.5 632.69749971 354.5a95.30250029 95.30250029 0 0 0-61.65749971 25.12500029c-3.62999971 3.59250029-9.0675 8.0775-15.4125 13.46249971-45.3375 37.69499971-186.795 166.0275-190.4175 169.61249971a58.94250029 58.94250029 0 0 1-39.89999971 16.155 69.21749971 69.21749971 0 0 1-43.50000058-20.63999971A1055.0475 1055.0475 0 0 1 166.625 448.73c-34.45499971-38.5875-63.4725-72.69000029-63.4725-72.69000029s-6.345 12.56249971-9.97499971 22.4325a17.02500029 17.02500029 0 0 0 0.90749971 14.36250058C113.12749971 439.75999971 199.25 541.16749971 317.15 642.57499971A100.26749971 100.26749971 0 0 0 383.33750029 669.5a95.30250029 95.30250029 0 0 0 61.65749971-25.12500029c3.62999971-3.59250029 9.0675-8.0775 15.4125-13.46249971 45.3375-37.69499971 131.48250029-115.76999971 171.37500029-152.56500029 9.0675-8.0775 15.4125-13.46249971 18.135-16.155a58.94250029 58.94250029 0 0 1 39.89999971-16.155 74.295 74.295 0 0 1 43.49999971 20.64000058c25.38749971 21.53999971 66.195 59.24999971 115.155 109.49999942 35.36250029 37.69499971 63.4725 71.7975 63.4725 72.69000029 0 0 6.345-13.46249971 9.97500058-22.4325a16.12500029 16.12500029 0 0 0 0.93749942-16.18499971z" fill="#8DC21F" p-id="4708"></path><path d="M469.61 235.0475c-39.8025 28.95000029-202.635 180.0225-234.29999971 216.20999971a550.50000029 550.50000029 0 0 0 56.99999971 53.40000058 19.8 19.8 0 0 0 28.95000029-3.61500029c62.42249971-63.32249971 190.88250029-176.40749971 208.06499971-189.97499971 47.9475-40.70999971 104.94-44.325 155.59499971-6.33000058 124.83749971 94.05 236.10750029 241.50750029 255.105 268.65 0 0 2.71500029-13.5675 4.5-25.32749942a28.5975 28.5975 0 0 0-3.61499942-20.80500029c-117.60000029-169.1775-242.42249971-274.11000029-290.37000058-300.34500029-54.27749971-28.04249971-117.60000029-38.0025-180.92999971 8.13750029z" fill="#0084CF" p-id="4709"></path><path d="M337.50500029 147.2975c-80.4825 65.13000029-184.5 161.02500029-210.75000029 189.97499971a706.5 706.5 0 0 0 50.66250029 54.27750058 11.12249971 11.12249971 0 0 0 17.19-1.80750058c79.605-76.89750029 165.54750029-155.60250029 216.2025-194.50499942 84.13499971-65.1375 183.645-71.46749971 280.44 5.42999971a1192.66499971 1192.66499971 0 0 1 114.89249971 104.94 1622.99999971 1622.99999971 0 0 1 141.12 180 206.505 206.505 0 0 0-4.5-37.99500029 57.83249971 57.83249971 0 0 0-5.42999971-14.47499971 857.025 857.025 0 0 0-100.43250029-145.59000029C728.3375 160.865 660.49250029 122.86999971 616.16750029 99.35 557.36 69.49999971 444.28249971 60.4475 337.50500029 147.2975z" fill="#0084CF" p-id="4710"></path><path d="M554.6375 782.36c39.8025-28.95000029 202.64249971-180 234.29999971-216.20999971a615.465 615.465 0 0 0-56.99999971-54.27750058 19.8 19.8 0 0 0-28.95000029 3.61500029c-62.3925 63.32249971-190.85249971 176.40749971-208.04249971 189.97499971-47.9475 40.70999971-104.94 44.325-156.50250029 6.33000058C213.605 617.71249971 103.23500029 470.25500029 84.23749971 442.21249971c0 0-2.71500029 13.5675-4.5 25.32750029a28.5975 28.5975 0 0 0 3.6 20.8125c117.60000029 169.16249971 242.4375 274.11000029 290.385 300.35999971 54.27749971 29.82750029 117.6075 39.75000029 180.91500029-6.35249971z" fill="#0084CF" p-id="4711"></path><path d="M686.75000029 870.10250029c80.51249971-65.1375 184.545-161.02500029 210.74999942-189.97500058a706.5 706.5 0 0 0-50.66249942-54.27749971 11.12249971 11.12249971 0 0 0-17.19 1.80749971C750.05 704.555 664.10749971 783.26 613.4525 822.1625c-84.13499971 65.1375-183.645 71.46749971-280.44-5.42999971a1192.67250029 1192.67250029 0 0 1-114.89249971-104.9625A1622.99999971 1622.99999971 0 0 1 77.00000029 531.77000029a214.43249971 214.43249971 0 0 0 4.5 37.99499942 57.83249971 57.83249971 0 0 0 5.45249971 14.50500029 857.025 857.025 0 0 0 100.4175 145.64999971c108.55500029 126.60000029 176.4 164.61 220.725 188.1 58.80750029 29.88749971 171.88499971 38.93249971 278.65500029-47.91749942z" fill="#0084CF" p-id="4712"></path></svg>
\ No newline at end of file
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Cache-Control" content="no-cache, no-store">
<script type="module" crossorigin src="./assets/index-BGNM-WBG.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CKDo6wGH.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
\ No newline at end of file
armo-xm项目重构-前端
<template>
<n-config-provider
:theme="appStore.isDark ? darkTheme : null"
:theme-overrides="appStore.isDark ? darkThemeOverrides : lightThemeOverrides"
:locale="zhCN"
:date-locale="dateZhCN"
>
<n-loading-bar-provider>
<n-message-provider>
<n-notification-provider>
<n-dialog-provider>
<!-- 全局内容 -->
<div class="h-screen w-full bg-body overflow-hidden transition-colors duration-300">
<router-view></router-view>
</div>
<!-- 全局阻塞式加载蒙层 (极简版) -->
<transition name="loading-fade">
<div v-if="appStore.loading" class="global-loading-overlay">
<n-spin size="large">
<template #description>
<span class="loading-text">{{ appStore.loadingText }}</span>
</template>
</n-spin>
</div>
</transition>
</n-dialog-provider>
</n-notification-provider>
</n-message-provider>
</n-loading-bar-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import { zhCN, dateZhCN, darkTheme, type GlobalThemeOverrides } from 'naive-ui'
import { useAppStore } from '@/store/app/index'
import { lightThemeConfig, darkThemeConfig } from '@/configs/tailwind.ui.config'
const appStore = useAppStore()
// 根据 borderRadius 值生成 Naive UI 的 borderRadius 字符串
const getBorderRadius = () => `${appStore.borderRadius * 16}px`
const getFontSize = () => `${appStore.fontSize}px`
// 计算 Naive UI 的主题覆写(响应式 computed)
const lightThemeOverrides = computed<GlobalThemeOverrides>(() => ({
common: {
...lightThemeConfig,
primaryColor: appStore.primaryColor,
primaryColorHover: appStore.primaryColor + 'cc',
primaryColorPressed: appStore.primaryColor + '99',
primaryColorSuppl: appStore.primaryColor,
borderRadius: getBorderRadius(),
borderRadiusSmall: getBorderRadius(),
fontSize: getFontSize(),
fontSizeMedium: getFontSize(),
fontSizeLarge: `${appStore.fontSize + 2}px`,
fontSizeSmall: `${appStore.fontSize - 2}px`,
fontSizeTiny: `${appStore.fontSize - 2}px`
},
Layout: {
siderToggleBarColor: 'rgba(0, 0, 0, 0.05)',
siderToggleBarColorHover: appStore.primaryColor + '33'
}
}))
const darkThemeOverrides = computed<GlobalThemeOverrides>(() => ({
common: {
...darkThemeConfig,
primaryColor: appStore.primaryColor,
primaryColorHover: appStore.primaryColor + 'cc',
primaryColorPressed: appStore.primaryColor + '99',
primaryColorSuppl: appStore.primaryColor,
borderRadius: getBorderRadius(),
borderRadiusSmall: getBorderRadius(),
fontSize: getFontSize(),
fontSizeMedium: getFontSize(),
fontSizeLarge: `${appStore.fontSize + 2}px`,
fontSizeSmall: `${appStore.fontSize - 2}px`,
fontSizeTiny: `${appStore.fontSize - 2}px`
},
Layout: {
siderToggleBarColor: 'rgba(255, 255, 255, 0.1)',
siderToggleBarColorHover: appStore.primaryColor + '40'
}
}))
// 同步主题变量到 CSS 根节点,供 Tailwind 使用
const updateCssVariables = (config: any) => {
const root = document.documentElement
Object.entries(config).forEach(([key, value]) => {
if (typeof value === 'string') {
root.style.setProperty(`--${key}`, value)
}
})
// 始终用 appStore 的动态值覆盖
root.style.setProperty('--primary-color', appStore.primaryColor)
const semanticMap: Record<string, string> = {
'--primary-color': appStore.primaryColor,
'--primary-color-hover': config.primaryColorHover,
'--primary-color-pressed': config.primaryColorPressed,
'--success-color': config.successColor,
'--success-color-hover': config.successColorHover,
'--success-color-pressed': config.successColorPressed,
'--warning-color': config.warningColor,
'--warning-color-hover': config.warningColorHover,
'--warning-color-pressed': config.warningColorPressed,
'--error-color': config.errorColor,
'--error-color-hover': config.errorColorHover,
'--error-color-pressed': config.errorColorPressed,
'--body-color': config.bodyColor,
'--card-color': config.cardColor,
'--divider-color': config.dividerColor,
'--border-color': config.borderColor
}
Object.entries(semanticMap).forEach(([key, value]) => {
if (value) root.style.setProperty(key, value)
})
}
// 应用字体大小到 body
const applyFontSize = (size: number) => {
document.documentElement.style.fontSize = `${size}px`
}
// 应用色弱/灰色模式
const applyFilterModes = () => {
document.body.classList.toggle('color-weak', appStore.colorWeak)
document.body.classList.toggle('gray-mode', appStore.grayMode)
}
// 监听主题(暗/亮)变化
watch(
() => appStore.isDark,
(isDark) => {
if (isDark) {
document.documentElement.classList.add('dark')
updateCssVariables(darkThemeConfig)
} else {
document.documentElement.classList.remove('dark')
updateCssVariables(lightThemeConfig)
}
},
{ immediate: true }
)
// 监听主题色变化
watch(
() => appStore.primaryColor,
(color) => {
document.documentElement.style.setProperty('--primary-color', color)
}
)
// 监听字体大小变化
watch(
() => appStore.fontSize,
(size) => {
applyFontSize(size)
},
{ immediate: true }
)
// 监听色弱/灰色模式变化
watch(
[() => appStore.colorWeak, () => appStore.grayMode],
() => {
applyFilterModes()
},
{ immediate: true }
)
</script>
<style>
body {
margin: 0;
padding: 0;
}
.bg-body {
background-color: var(--body-color);
}
/* 色弱模式 */
body.color-weak {
filter: invert(80%) hue-rotate(180deg);
}
/* 灰色模式 */
body.gray-mode {
filter: grayscale(100%);
}
.global-loading-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
}
.loading-text {
margin-top: 12px;
font-size: 14px;
color: var(--primary-color);
letter-spacing: 1px;
}
.loading-fade-enter-active,
.loading-fade-leave-active {
transition: opacity 0.3s ease;
}
.loading-fade-enter-from,
.loading-fade-leave-to {
opacity: 0;
}
</style>
<style scoped></style>
This source diff could not be displayed because it is too large. You can view the blob instead.
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
\ No newline at end of file
<template>
<n-button v-if="isVisible" v-bind="$attrs">
<!-- 插槽转发 -->
<template v-for="slotName in Object.keys($slots)" :key="slotName" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps || {}" />
</template>
</n-button>
</template>
<script setup lang="ts">
/**
* 全局通用权限按钮组件
* 对齐 SHTML 的 auth 指令逻辑,无权限不渲染
*/
interface Props {
/** 权限标识 (支持字符串或数组) */
auth?: string | string[]
/** 显隐控制 (对齐 SHTML 逻辑) */
display?: boolean
}
const props = withDefaults(defineProps<Props>(), {
display: true
})
const isVisible = computed(() => {
// 1. 如果 display 为 false,直接不显示
if (!props.display) {
return false
}
// 2. 如果没有定义 auth 属性,则默认展示
if (!props.auth || (Array.isArray(props.auth) && props.auth.length === 0)) {
return true
}
// 3. 校验权限
return true
})
</script>
<template>
<n-checkbox-group v-model:value="computedValue" v-bind="$attrs" @update:value="handleUpdateValue">
<n-grid v-if="gridCols" :cols="gridCols" :x-gap="20" :y-gap="10">
<n-gi v-for="opt in computedOptions" :key="String(opt.value)">
<n-checkbox :value="opt.value" :disabled="opt.disabled">
{{ opt.label }}
</n-checkbox>
</n-gi>
</n-grid>
<n-space v-else :size="spaceSize">
<n-checkbox v-for="opt in computedOptions" :key="String(opt.value)" :value="opt.value" :disabled="opt.disabled">
{{ opt.label }}
</n-checkbox>
</n-space>
</n-checkbox-group>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
const props = defineProps({
/** 绑定的值 (v-model) */
value: {
type: [Array, String] as PropType<any[] | string | null | undefined>,
default: () => []
},
modelValue: {
type: [Array, String] as PropType<any[] | string | null | undefined>,
default: () => []
},
/** 静态选项 */
options: {
type: Array as PropType<any[]>,
default: null
},
/** 严格模式:默认不开启(不开启时自动转字符串比对,不区分 1 和 '1') */
strict: {
type: Boolean,
default: false
},
/** 选项之间的间距 (n-space size) */
spaceSize: {
type: [String, Number, Array] as PropType<number | 'small' | 'medium' | 'large' | [number, number]>,
default: 'medium'
},
/** 网格布局列数,如果不传则使用 n-space */
gridCols: {
type: [Number, String] as PropType<number | string>,
default: 0
}
})
const emit = defineEmits(['update:value', 'update:modelValue', 'change'])
/**
* 值的标准化处理
*/
const normalizeValue = (v: any) => {
if (v === null || v === undefined || v === '') return null
return props.strict ? v : String(v)
}
const computedValue = computed<any>({
get: () => {
const val = props.modelValue ?? props.value ?? []
// 兼容性处理:如果传入的是非数组(如字符串或 null),则尝试转换为数组
let arrayVal: any[] = []
if (Array.isArray(val)) {
arrayVal = val
} else if (typeof val === 'string' && val !== '') {
arrayVal = val.split(',')
}
return arrayVal.map(normalizeValue).filter((v) => v !== null)
},
set: (val: any) => {
emit('update:value', val)
emit('update:modelValue', val)
}
})
const handleUpdateValue = (val: any) => {
emit('change', val)
}
const computedOptions = computed(() => {
let rawOptions: any[] = []
if (props.options) {
rawOptions = props.options
}
return rawOptions.map((opt: any) => {
const label = opt.TEXT || opt.name || opt.label || opt.REAL_NAME || opt.MENU_NAME || opt.title
const rawVal = opt.VALUE !== undefined ? opt.VALUE : (opt.id ?? opt.value ?? opt.PKID)
const value = normalizeValue(rawVal)
return {
...opt,
label,
value
}
})
})
</script>
<style scoped></style>
<template>
<n-date-picker
v-model:value="internalValue"
:type="type"
clearable
:shortcuts="showShortcuts ? (isRange ? RANGE_SHORTCUTS : undefined) : undefined"
class="w-full"
v-bind="$attrs"
@update:value="handleUpdate"
/>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
/**
* 统一通用的日期/时间选择组件 (CommonDatePicker)
*
* 强制规范:
* 1. 业务层统一使用字符串交互,避免 Date 对象或时间戳处理。
* 2. 范围选择建议绑定 v-model:start 和 v-model:end。
* 3. 单选建议绑定 v-model:value。
* 4. 自动补全 00:00:00/23:59:59 以符合后端查询习惯(仅在 range 模式下默认开启)。
*/
// v-model 绑定
const modelValue = defineModel<string | number | null | undefined>()
const customValue = defineModel<string | number | null | undefined>('value')
const start = defineModel<string | number | null | undefined>('start')
const end = defineModel<string | number | null | undefined>('end')
const props = withDefaults(
defineProps<{
/** 选择类型,参考 Naive UI: date, datetime, daterange, datetimerange, month, year, quarter */
type?: 'date' | 'datetime' | 'daterange' | 'datetimerange' | 'month' | 'year' | 'quarter'
/** 日期部分的格式化模板,默认 YYYY-MM-DD */
format?: string
/** 是否包含时分秒补全 (仅对 range 模式有效) */
withTimePadding?: boolean
/** 是否显示快捷面板 */
showShortcuts?: boolean
}>(),
{
type: 'date',
withTimePadding: true,
showShortcuts: true
}
)
// 是否为 range 模式
const isRange = computed(() => props.type.includes('range'))
// 内部驱动 n-date-picker 的时间戳状态
const internalValue = ref<number | [number, number] | null>(null)
/** 默认快捷面板配置 */
const RANGE_SHORTCUTS = {
今天: () => [dayjs().startOf('day').valueOf(), dayjs().endOf('day').valueOf()] as [number, number],
昨天: () => [dayjs().subtract(1, 'day').startOf('day').valueOf(), dayjs().subtract(1, 'day').endOf('day').valueOf()] as [number, number],
最近7: () => [dayjs().subtract(6, 'day').startOf('day').valueOf(), dayjs().endOf('day').valueOf()] as [number, number],
本月: () => [dayjs().startOf('month').valueOf(), dayjs().endOf('month').valueOf()] as [number, number],
最近30: () => [dayjs().subtract(29, 'day').startOf('day').valueOf(), dayjs().endOf('day').valueOf()] as [number, number]
}
/**
* 同步:从外部字符串同步到内部时间戳
*/
const instance = getCurrentInstance()
const hasValueBind = computed(() => {
const props = instance?.vnode.props || {}
return 'value' in props || 'onUpdate:value' in props
})
watch(
[modelValue, customValue, start, end],
() => {
if (isRange.value) {
if (start.value && end.value) {
const s = dayjs(start.value).valueOf()
const e = dayjs(end.value).valueOf()
if (!isNaN(s) && !isNaN(e)) {
if (!Array.isArray(internalValue.value) || (internalValue.value as any)[0] !== s || (internalValue.value as any)[1] !== e) {
internalValue.value = [s, e]
}
} else {
internalValue.value = null
}
} else if (!start.value && !end.value) {
internalValue.value = null
}
} else {
const activeVal = hasValueBind.value ? customValue.value : modelValue.value
if (activeVal) {
const v = dayjs(activeVal).valueOf()
if (!isNaN(v)) {
if (internalValue.value !== v) {
internalValue.value = v as any
}
} else {
internalValue.value = null
}
} else {
internalValue.value = null
}
}
},
{ immediate: true }
)
/**
* 处理更新:从内部时间戳格式化至外部字符串
*/
const handleUpdate = (val: number | [number, number] | null) => {
const fmt = props.format || (props.type.includes('time') ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD')
if (!val) {
if (isRange.value) {
start.value = null
end.value = null
} else {
modelValue.value = null
customValue.value = null
}
return
}
if (isRange.value && Array.isArray(val)) {
const [s, e] = val
if (props.withTimePadding && !props.type.includes('time')) {
// 非 datetime 模式下,自动补全 00:00:00 和 23:59:59
start.value = dayjs(s).format(fmt) + ' 00:00:00'
end.value = dayjs(e).format(fmt) + ' 23:59:59'
} else {
start.value = dayjs(s).format(fmt)
end.value = dayjs(e).format(fmt)
}
} else if (!Array.isArray(val)) {
const formatted = dayjs(val as number).format(fmt)
modelValue.value = formatted
customValue.value = formatted
}
}
</script>
<template>
<CommonModal
v-model="show"
title=""
:width="420"
:closable="!loading"
:show-confirm="false"
:show-cancel="false"
:segmented="false"
:header-style="{ display: 'none' }"
:footer-style="{ display: 'none' }"
content-style="padding: 0; background: transparent;"
class="download-progress-modal"
>
<div
class="relative overflow-hidden rounded-2xl bg-fill-1/80 backdrop-blur-xl p-4 border border-color1/20"
>
<!-- 装饰性背景光效 -->
<div class="absolute -top-24 -right-24 w-48 h-48 bg-primary/10 rounded-full blur-3xl animate-pulse"></div>
<div class="absolute -bottom-24 -left-24 w-48 h-48 bg-primary/5 rounded-full blur-3xl animate-pulse" style="animation-delay: 1s"></div>
<div class="relative z-10">
<div class="flex items-center justify-between mb-6">
<div class="flex flex-col">
<n-text class="text-lg font-bold tracking-tight text-color1">正在处理文件</n-text>
<n-text depth="3" class="text-xs mt-0.5 opacity-60">
{{ progress === 100 ? '已准备就绪' : '请勿关闭当前窗口' }}
</n-text>
</div>
<div class="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 text-primary">
<div class="relative">
<DownloadOutline class="text-2xl animate-bounce" v-if="progress < 100" />
<CheckmarkCircleOutline class="text-2xl text-success" v-else />
</div>
</div>
</div>
<div class="space-y-3">
<div class="flex justify-between items-end px-0.5">
<n-text class="text-xs font-mono font-medium opacity-50">{{ fileName }}</n-text>
<n-text class="text-2xl font-black italic tracking-tighter text-primary">{{ Math.floor(progress) }}%</n-text>
</div>
<div class="h-3 w-full bg-fill-3 rounded-full overflow-hidden p-[2px]">
<div
class="h-full rounded-full transition-all duration-300 ease-out relative"
:style="{
width: `${progress}%`,
background: themeVars.primaryColor,
boxShadow: `0 0 15px ${themeVars.primaryColor}40`
}"
>
<!-- 进度条高光流光效果 -->
<div
class="absolute inset-0 w-full h-full bg-gradient-to-r from-transparent via-white/30 to-transparent skew-x-[-20deg] animate-shimmer"
></div>
</div>
</div>
</div>
</div>
</div>
</CommonModal>
</template>
<script setup lang="ts">
import { DownloadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5'
interface DownloadOptions {
api?: string
params?: any
fileName?: string
title?: string
callback?: (success: boolean) => void
}
const themeVars = useThemeVars()
const show = ref(false)
const loading = ref(false)
const progress = ref(0)
const title = ref('文件下载')
const fileName = ref('')
let progressTimer: any = null
/**
* 打开下载弹窗并立即开始下载
* @param options 下载配置
*/
const open = async (options: DownloadOptions) => {
title.value = options.title || '下载模板'
fileName.value = options.fileName || '正在准备下载文件...'
progress.value = 0
loading.value = true
show.value = true
// 开启模拟进度条定时器
progressTimer = setInterval(() => {
if (progress.value < 90) {
progress.value += Math.floor(Math.random() * 5) + 2
} else if (progress.value < 98) {
progress.value += 0.5
}
}, 150)
const { api = '/v1/plugins/ATTACHMENT_DOWN', params = {}, fileName: name } = options
try {
const success = await service.download(api, params, name, { showLoading: false })
if (success) {
progress.value = 100
window.$message.success('下载任务已完成')
// 延迟关闭,让用户看到 100% 成功状态
setTimeout(() => {
show.value = false
options.callback?.(true)
}, 800)
} else {
show.value = false
options.callback?.(false)
}
} catch (err) {
console.error('Download failed', err)
progress.value = 0
window.$message.error('下载失败,请重试')
show.value = false
options.callback?.(false)
} finally {
loading.value = false
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
}
}
defineExpose({ open })
</script>
<style scoped>
@keyframes shimmer {
0% {
transform: translateX(-100%) skewX(-20deg);
}
100% {
transform: translateX(200%) skewX(-20deg);
}
}
.animate-shimmer {
animation: shimmer 2s infinite linear;
}
:deep(.download-progress-modal.n-modal) {
background: transparent !important;
}
/* 兼容 Naive UI card preset 的内部样式覆盖 */
:deep(.download-progress-modal .n-card-header),
:deep(.download-progress-modal .n-card__footer) {
display: none !important;
}
:deep(.download-progress-modal .n-card__content) {
padding: 0 !important;
}
</style>
<template>
<CommonModal v-model="show" title="导出确认" style="width: 500px">
<div class="p-4 py-6">
<n-form-item label="选择导出范围" label-placement="left">
<n-radio-group v-model:value="exportMode" name="exportMode">
<n-space>
<n-radio value="page">当前页数据</n-radio>
<n-radio value="all">
全量数据
<n-text depth="3" class="text-xs ml-1">(最多 2,147,483,647 条)</n-text>
</n-radio>
</n-space>
</n-radio-group>
</n-form-item>
<n-alert v-if="exportMode === 'all'" title="提示" type="info" class="mt-4">全量导出可能会耗时较长,具体取决于系统数据总量。</n-alert>
<n-progress v-if="loading" type="line" :percentage="exportProgress" :indicator-placement="'inside'" processing class="mt-4" />
</div>
<template #footer>
<n-space justify="end">
<CommonButton @click="show = false" :disabled="loading">取消</CommonButton>
<CommonButton type="primary" :loading="loading" @click="handleConfirm">开始导出</CommonButton>
</n-space>
</template>
</CommonModal>
</template>
<script setup lang="ts">
import { service } from '@/api/index'
const show = ref(false)
const loading = ref(false)
const exportProgress = ref(0)
const exportMode = ref<'page' | 'all'>('page')
let progressTimer: any = null
// 导出上下文
const context = ref<{
fileName: string
functionCode: string
params: any
} | null>(null)
/**
* 打开导出选择弹窗
* @param options 导出配置
*/
const open = (options: { fileName: string; functionCode: string; params: any }) => {
context.value = options
exportMode.value = 'page' // 默认当前页
show.value = true
}
/** 执行下载 */
const handleConfirm = async () => {
if (!context.value) return
loading.value = true
exportProgress.value = 0
// 开启模拟进度条定时器
progressTimer = setInterval(() => {
if (exportProgress.value < 90) {
exportProgress.value += Math.floor(Math.random() * 3) + 1
} else if (exportProgress.value < 98) {
exportProgress.value += 0.3
}
}, 200)
const { fileName, functionCode, params } = context.value
const exportParams = {
...params,
functionCode,
fileName,
page: exportMode.value === 'all' ? 1 : params.page || 1,
rows: exportMode.value === 'all' ? 2147483647 : params.rows || 15
}
try {
const success = await service.download('/excel/export', exportParams, `${fileName}_${new Date().getTime()}.xlsx`, {
showLoading: false
})
if (success) {
exportProgress.value = 100
window.$message.success('导出任务已启动,请查看浏览器下载')
// 延迟关闭,让用户看到 100% 进度
setTimeout(() => {
show.value = false
}, 500)
}
} catch (err) {
console.error('Export failed', err)
exportProgress.value = 0
} finally {
loading.value = false
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
}
}
defineExpose({ open })
</script>
<template>
<CommonModal v-model="showModal" :title="title" style="width: 500px">
<div class="p-6">
<n-upload v-model:file-list="fileList" :default-upload="false" :max="1" action="#" @before-upload="beforeUpload" @remove="handleRemove">
<n-upload-dragger v-if="fileList.length === 0">
<div class="mb-3">
<n-icon size="48" :depth="3">
<CloudUploadOutline />
</n-icon>
</div>
<n-text style="font-size: 16px">点击或将文件拖拽到这里上传</n-text>
<n-p depth="3" style="margin: 8px 0 0 0">仅支持 .xlsx 格式文件</n-p>
</n-upload-dragger>
</n-upload>
<n-progress
v-if="uploading || importResult?.success"
type="line"
:percentage="uploadProgress"
:indicator-placement="'inside'"
processing
class="mt-4"
/>
<n-alert v-if="importResult" class="mt-4" title="导入结果" :type="importResult.success ? 'success' : 'error'">
{{ importResult.message }}
</n-alert>
</div>
<template #footer>
<div class="flex justify-between items-center w-full">
<CommonButton v-if="showDownload" type="primary" secondary @click="handleDownloadTemplate">
<template #icon>
<n-icon><DownloadOutline /></n-icon>
</template>
下载导入模板
</CommonButton>
<div v-else></div>
<n-space>
<CommonButton @click="showModal = false">取消</CommonButton>
<CommonButton
type="primary"
:loading="uploading"
:disabled="!importResult?.success && fileList.length === 0"
@click="handleConfirm"
>
{{ importResult?.success ? '完成' : '确定' }}
</CommonButton>
</n-space>
</div>
</template>
</CommonModal>
</template>
<script setup lang="ts">
import { CloudUploadOutline, DownloadOutline } from '@vicons/ionicons5'
import type { UploadFileInfo } from 'naive-ui'
import { service } from '@/api/index'
interface ImportOptions {
title?: string
api: string
/** 模板名称/标识 */
templateName?: string
/** 模板下载接口 (可选,不传则使用默认下载逻辑) */
templateApi?: string
templateTitle?: string
data?: any
downloadParams?: any
onSuccess?: () => void
fileKey?: string
}
const showModal = ref(false)
const uploading = ref(false)
const uploadProgress = ref(0)
const fileList = ref<UploadFileInfo[]>([])
const importResult = ref<{ success: boolean; message: string } | null>(null)
const context = ref<ImportOptions | null>(null)
let progressTimer: any = null
const title = computed(() => context.value?.title || '批量导入')
const showDownload = computed(() => !!(context.value?.templateName || context.value?.templateApi))
/**
* 打开导入弹窗
* @param options 导入配置项
*/
const open = (options: ImportOptions) => {
context.value = options
showModal.value = true
importResult.value = null
fileList.value = []
}
const beforeUpload = (data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) => {
const file = data.file.file
if (file) {
const isExcel = file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || file.name.endsWith('.xlsx')
if (!isExcel) {
window.$message.error('只能上传 .xlsx 格式的文件')
return false
}
}
return true
}
const handleRemove = () => {
importResult.value = null
}
const handleConfirm = async () => {
const ctx = context.value
// 如果已经成功导入,再次点击确定则直接关闭弹窗
if (importResult.value?.success) {
showModal.value = false
return
}
if (fileList.value.length === 0 || !ctx?.api) return
uploading.value = true
uploadProgress.value = 0
importResult.value = null
// 开启模拟进度条定时器
progressTimer = setInterval(() => {
if (uploadProgress.value < 90) {
uploadProgress.value += Math.floor(Math.random() * 5) + 1
} else if (uploadProgress.value < 98) {
uploadProgress.value += 0.5
}
}, 200)
try {
const formData = new FormData()
const file = fileList.value[0]?.file
if (file) {
const key = ctx.fileKey || 'file'
formData.append(key, file)
}
// 合并业务参数
const extraData = ctx.data || {}
Object.keys(extraData).forEach((key) => {
formData.append(key, extraData[key])
})
// 禁用全局 showLoading,改用本地进度条
const res = await service.post(ctx.api, formData, { showLoading: false })
if (res.code === 200) {
window.$message.success('导入成功')
uploadProgress.value = 100
// 如果返回的是技术代码,转换一下显示效果
const msg = res.msg && res.msg.startsWith('ERRMSG.') ? '导入操作已成功完成' : res.msg || '导入成功'
importResult.value = {
success: true,
message: msg
}
fileList.value = [] // 成功后清除文件列表,防止重复点确定
ctx.onSuccess?.()
emit('success')
setTimeout(() => {
showModal.value = false
}, 1000)
} else {
uploadProgress.value = 0
// 拦截器通常会自动处理非 200 的报错,但这里我们可能需要显示结果到区域
importResult.value = {
success: false,
message: res.msg || '导入失败'
}
}
} catch (e: any) {
console.error('Upload failed', e)
uploadProgress.value = 0
importResult.value = {
success: false,
message: e.message || '上传异常'
}
} finally {
uploading.value = false
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
}
}
const handleDownloadTemplate = () => {
if (!context.value?.templateName && !context.value?.templateApi) return
const params = {
...(context.value.templateName ? { filename: context.value.templateName } : {}),
...(context.value.downloadParams || {})
}
const downloadApi = context.value.templateApi || '/v1/plugins/ATTACHMENT_DOWN'
openDownloadModal({
api: downloadApi,
params,
fileName: context.value.templateTitle || '导入模板.xlsx',
title: '下载导入模板'
})
}
const emit = defineEmits(['success'])
defineExpose({
open
})
</script>
<template>
<n-input-number
v-model:value="internalValue"
:disabled="disabled"
:min="min"
:max="max"
:step="step"
:precision="precision"
:clearable="clearable"
:show-button="showButton"
v-bind="$attrs"
@update:value="handleUpdateValue"
>
<template v-for="(_, name) in $slots" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}"></slot>
</template>
</n-input-number>
</template>
<script setup lang="ts">
const props = defineProps({
/** 绑定的值 (v-model) */
value: {
type: [String, Number, Boolean] as PropType<string | number | boolean | null | undefined>,
default: null
},
modelValue: {
type: [String, Number, Boolean] as PropType<string | number | boolean | null | undefined>,
default: null
},
/** 是否显示加减按钮 */
showButton: {
type: Boolean,
default: false
},
/** 严格模式:默认不开启(不开启时自动转字符串比对,不区分 1 和 '1') */
strict: {
type: Boolean,
default: false
},
/** 禁用状态 */
disabled: {
type: Boolean,
default: false
},
/** 最小值 */
min: {
type: Number,
default: undefined
},
/** 最大值 */
max: {
type: Number,
default: undefined
},
/** 步长 */
step: {
type: Number,
default: undefined
},
/** 精度 */
precision: {
type: Number,
default: undefined
},
/** 是否显示清除按钮 */
clearable: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:value', 'update:modelValue', 'change'])
/**
* 将外部值转换为内部数字值
*/
const toInternalValue = (v: any): number | null => {
if (v === null || v === undefined || v === '') return null
if (typeof v === 'boolean') return v ? 1 : 0
if (typeof v === 'string') {
const num = Number(v)
return isNaN(num) ? null : num
}
return typeof v === 'number' ? v : null
}
/**
* 将内部数字值转换为外部值
*/
const toExternalValue = (v: number | null): any => {
if (v === null || v === undefined) return null
return props.strict ? v : String(v)
}
const internalValue = ref<number | null>(toInternalValue(props.modelValue ?? props.value))
watch(
() => props.modelValue ?? props.value,
(newVal) => {
internalValue.value = toInternalValue(newVal)
}
)
const handleUpdateValue = (val: number | null) => {
const result = toExternalValue(val)
emit('update:value', result)
emit('update:modelValue', result)
emit('change', result)
}
</script>
<style scoped></style>
<template>
<n-modal
v-model:show="showValue"
preset="card"
:title="title"
:segmented="{ content: true, footer: 'soft' }"
:mask-closable="!loading"
:closable="!loading"
:draggable="{ bounds: 'none' }"
v-bind="$attrs"
:style="modalStyle"
class="app-modal"
header-class="!px-[20px] !py-[15px]"
content-class="!p-0"
footer-class="!m-0 !p-0"
>
<template #header-extra>
<slot name="header-extra"></slot>
</template>
<n-scrollbar :style="{ maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight }">
<n-spin :show="loading" :style="{ padding: typeof padding === 'number' ? `${padding}px` : padding }">
<slot></slot>
</n-spin>
</n-scrollbar>
<template #footer v-if="showFooter">
<n-space justify="end" align="center" class="p-[10px]">
<slot name="footer">
<slot name="footer-extra"></slot>
<CommonButton v-if="showCancel" @click="handleCancel" :disabled="loading">
{{ cancelText }}
</CommonButton>
<CommonButton v-if="showConfirm" type="primary" :loading="loading" @click="handleConfirm">
{{ confirmText }}
</CommonButton>
</slot>
</n-space>
</template>
</n-modal>
</template>
<script setup lang="ts">
interface Props {
modelValue: boolean
title?: string
loading?: boolean
confirmText?: string
cancelText?: string
showConfirm?: boolean
showCancel?: boolean
showFooter?: boolean
width?: string | number
maxHeight?: string | number
padding?: string | number
}
const props = withDefaults(defineProps<Props>(), {
title: '提示',
loading: false,
confirmText: '确定',
cancelText: '取消',
showConfirm: true,
showCancel: true,
showFooter: true,
width: '600px',
maxHeight: '75vh',
padding: '15px'
})
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
const modalStyle = computed(() => {
const w = props.width
return {
width: typeof w === 'number' ? `${w}px` : w
}
})
const showValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const handleConfirm = () => {
emit('confirm')
}
const handleCancel = () => {
showValue.value = false
emit('cancel')
}
</script>
<style></style>
<style scoped></style>
<template>
<n-radio-group v-model:value="computedValue" v-bind="$attrs" @update:value="handleUpdateValue">
<n-space :size="spaceSize">
<template v-if="type === 'button'">
<n-radio-button v-for="opt in computedOptions" :key="String(opt.value)" :value="opt.value" :disabled="opt.disabled">
{{ opt.label }}
</n-radio-button>
</template>
<template v-else>
<n-radio v-for="opt in computedOptions" :key="String(opt.value)" :value="opt.value" :disabled="opt.disabled">
{{ opt.label }}
</n-radio>
</template>
</n-space>
</n-radio-group>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
const props = defineProps({
/** 绑定的值 (v-model) */
value: {
type: [String, Number, Boolean] as PropType<string | number | boolean | null | undefined>,
default: null
},
modelValue: {
type: [String, Number, Boolean] as PropType<string | number | boolean | null | undefined>,
default: null
},
/** 静态选项 */
options: {
type: Array as PropType<any[]>,
default: null
},
/** 显示类型: radio 或 button */
type: {
type: String as PropType<'radio' | 'button'>,
default: 'radio'
},
/** 严格模式:默认不开启(不开启时自动转字符串比对,不区分 1 和 '1') */
strict: {
type: Boolean,
default: false
},
/** 选项之间的间距 (n-space size) */
spaceSize: {
type: [String, Number, Array] as PropType<number | 'small' | 'medium' | 'large' | [number, number]>,
default: 'medium'
}
})
const emit = defineEmits(['update:value', 'update:modelValue', 'change'])
/**
* 值的标准化处理
*/
const normalizeValue = (v: any) => {
if (v === null || v === undefined || v === '') return null
// 如果是布尔值且非严格模式,转为字符串
if (typeof v === 'boolean' && !props.strict) return String(v)
return props.strict ? v : String(v)
}
const computedValue = computed<any>({
get: () => normalizeValue(props.modelValue ?? props.value),
set: (val: any) => {
// 如果是非严格模式,保持为 String 以匹配选项;如果需要数字,建议业务层自行处理或开启 strict
emit('update:value', val)
emit('update:modelValue', val)
}
})
const handleUpdateValue = (val: any) => {
emit('change', val)
}
const computedOptions = computed(() => {
let rawOptions: any[] = []
if (props.options) {
rawOptions = props.options
}
return rawOptions.map((opt: any) => {
const label = opt.TEXT || opt.name || opt.label || opt.REAL_NAME || opt.MENU_NAME || opt.title
const rawVal = opt.VALUE !== undefined ? opt.VALUE : (opt.id ?? opt.value ?? opt.PKID)
const value = normalizeValue(rawVal)
return {
...opt,
label,
value
}
})
})
</script>
<style scoped></style>
<template>
<n-select
:value="computedValue"
:options="computedOptions"
:loading="isLoading"
:clearable="true"
v-bind="$attrs"
:filterable="filterable"
:consistent-menu-width="consistentMenuWidth"
@update:value="handleUpdateValue"
/>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Number, Array] as PropType<string | number | any[] | null | undefined>,
default: null
},
// 接口 URL
api: {
type: String,
default: ''
},
// 接口参数
params: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
beforeRequest: {
type: Function as PropType<(params: any) => any>,
default: undefined
},
afterResponse: {
type: Function as PropType<(data: any[]) => any[]>,
default: (data: any[]) => data
},
// 静态选项
options: {
type: Array as PropType<any[]>,
default: null
},
loading: {
type: Boolean,
default: false
},
// 严格模式:默认不开启(不开启时自动转字符串比对,不区分 1 和 '1')
strict: {
type: Boolean,
default: false
},
/**
* 是否保持菜单宽度与选择框一致
* @default false (不一致,自动适配内容宽度)
*/
consistentMenuWidth: {
type: Boolean,
default: false
},
/** 是否可搜索 */
filterable: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const apiOptions = ref<any[]>([])
const internalLoading = ref(false)
const isLoading = computed(() => internalLoading.value || props.loading)
/**
* 值的标准化处理
*/
const normalizeValue = (v: any) => {
if (v === null || v === undefined || v === '') return null
return props.strict ? v : String(v)
}
const computedValue = computed<any>({
get: () => {
if (Array.isArray(props.modelValue)) {
return props.modelValue.map(normalizeValue).filter((v) => v !== null)
}
return normalizeValue(props.modelValue)
},
set: (val: any) => {
emit('update:modelValue', val)
}
})
const handleUpdateValue = (val: any, option: any) => {
emit('update:modelValue', val)
emit('change', val, option)
}
const computedOptions = computed(() => {
let rawOptions: any[] = []
if (props.options) {
rawOptions = props.options
} else if (props.api) {
rawOptions = apiOptions.value
}
return rawOptions.filter(Boolean).map((opt: any) => {
// 兼容不同的字段名 (针对 DICT、接口返回、用户传入等场景)
const label = opt.TEXT || opt.name || opt.label || opt.REAL_NAME || opt.MENU_NAME || opt.title
const value = normalizeValue(opt.VALUE !== undefined ? opt.VALUE : (opt.id ?? opt.value ?? opt.PKID))
return {
...opt,
label,
value
}
})
})
const fetchApiOptions = async () => {
if (!props.api) return
internalLoading.value = true
try {
let requestParams: any = { ...props.params }
if (props.beforeRequest) {
requestParams = props.beforeRequest(requestParams) ?? requestParams
}
const res = await requestListData(props.api, requestParams)
if (res.success) {
const list = Array.isArray(res.data) ? res.data : []
apiOptions.value = props.afterResponse ? props.afterResponse(list) : list
}
} catch (e) {
console.error('Fetch select options failed:', e)
apiOptions.value = []
} finally {
internalLoading.value = false
}
}
// 监听 API 或参数变化
watch(
() => props.api,
(newApi) => {
if (newApi) fetchApiOptions()
},
{ immediate: true }
)
watch(
() => JSON.stringify(props.params),
(newVal, oldVal) => {
if (newVal !== oldVal && props.api) {
fetchApiOptions()
}
}
)
</script>
<template>
<div class="common-split-container w-full h-full overflow-hidden">
<n-split :direction="direction" :default-size="defaultSize" :min="min" :max="max" class="h-full">
<template #1>
<div class="split-pane h-full flex flex-col overflow-hidden">
<slot name="left" v-if="direction === 'horizontal'" />
<slot name="top" v-else />
</div>
</template>
<template #2>
<div class="split-pane h-full flex flex-col overflow-hidden">
<slot name="right" v-if="direction === 'horizontal'" />
<slot name="bottom" v-else />
</div>
</template>
<template #resize-trigger>
<div class="resize-trigger-wrapper" :class="direction">
<div class="resize-line" :class="direction" />
</div>
</template>
</n-split>
</div>
</template>
<script setup lang="ts">
const themeVars = useThemeVars()
interface Props {
/** 分割方向,vertical 垂直分割,horizontal 水平分割 */
direction?: 'vertical' | 'horizontal'
/** 默认大小比例 */
defaultSize?: number
/** 最小比例 */
min?: number
/** 最大比例 */
max?: number
}
const props = withDefaults(defineProps<Props>(), {
direction: 'vertical',
defaultSize: 0.5,
min: 0.2,
max: 0.8
})
/**
* CommonSplitLayout - 主从表/分栏标准分割布局组件
* 用于全项目所有“上方主表 + 下方子表”或“左侧树/表 + 右侧表”的场景,提供可拖拽的分割杆。
*/
defineSlots<{
/** 上方容器插槽 (vertical) */
top?(): any
/** 下方容器插槽 (vertical) */
bottom?(): any
/** 左侧容器插槽 (horizontal) */
left?(): any
/** 右侧容器插槽 (horizontal) */
right?(): any
}>()
</script>
<style scoped>
.common-split-container {
padding: 0;
}
.split-pane {
box-sizing: border-box;
padding: 0;
}
.resize-trigger-wrapper {
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
background-color: transparent;
transition: background-color 0.3s;
}
.resize-trigger-wrapper.vertical {
height: 4px;
width: 100%;
cursor: row-resize;
}
.resize-trigger-wrapper.horizontal {
width: 4px;
height: 100%;
cursor: col-resize;
}
.resize-trigger-wrapper:hover {
background-color: v-bind('themeVars.primaryColor + "1A"');
}
.resize-trigger-wrapper:hover .resize-line {
background-color: v-bind('themeVars.primaryColor');
}
.resize-line {
background-color: v-bind('themeVars.borderColor');
transition: all 0.3s;
}
.resize-line.vertical {
width: 100%;
height: 1px;
}
.resize-line.horizontal {
height: 100%;
width: 1px;
}
/* 兼容主题变量 */
:deep(.n-split__resize-trigger) {
background-color: transparent;
}
/*
* 关键修复:CommonTable 默认 minHeight='350px',
* 在 split-pane 中强制撑高表格导致分页被 overflow:hidden 裁掉。
* 覆盖为 0 后,flex-1 能正确接管高度计算,分页始终可见。
*/
.split-pane :deep(.common-table-container) {
height: 100% !important;
}
.split-pane :deep(.custom-data-table) {
min-height: 0 !important;
}
</style>
<template>
<n-popover
v-model:show="showPopover"
trigger="click"
placement="bottom-start"
:width="typeof popoverWidth === 'number' ? popoverWidth : undefined"
:style="{ padding: 0 }"
@update:show="onPopoverShow"
>
<template #trigger>
<n-input
v-model:value="displayLabel"
:placeholder="placeholder"
:disabled="disabled"
:loading="loading"
clearable
readonly
@clear="onClear"
>
<template #suffix>
<n-icon class="cursor-pointer">
<chevron-down-outline />
</n-icon>
</template>
</n-input>
</template>
<div
class="p-3 flex flex-col min-w-0"
:style="{ width: typeof popoverWidth === 'number' ? `${popoverWidth}px` : popoverWidth, maxHeight: '550px' }"
>
<div v-if="showSearch" class="mb-2">
<n-input-group>
<n-input
ref="searchInputRef"
v-model:value="searchKeyword"
placeholder="输入关键字搜索"
size="small"
@keyup.enter="handleSearch"
/>
<n-button type="primary" size="small" @click="handleSearch">搜索</n-button>
</n-input-group>
</div>
<div class="flex-1 min-h-0">
<CommonTable
ref="tableRef"
:api="api"
:columns="columns"
:search-params="combinedParams"
:show-pagination="showPagination"
:pagination-props="{ maxPages: 5 }"
:flex-height="false"
compact
:immediate="false"
choose
v-model:choose-row="selectedRow"
:row-key="(row: any) => row[valueField]"
:max-height="320"
:show-index="showIndex"
:after-response="handleTableResponse"
@success="onTableSuccess"
@select="onRowSelect"
/>
</div>
</div>
</n-popover>
</template>
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { ChevronDownOutline } from '@vicons/ionicons5'
import type { DataTableColumns } from 'naive-ui'
interface Props {
modelValue?: string | number | null
labelValue?: string
placeholder?: string
disabled?: boolean
loading?: boolean
api: string
params?: Record<string, any>
columns: DataTableColumns<any>
valueField?: string
labelField?: string
searchField?: string
popoverWidth?: number | string
showPagination?: boolean
immediate?: boolean
strict?: boolean
showSearch?: boolean
showIndex?: boolean
searchValue?: 'value' | 'label'
filterData?: boolean
}
const props = withDefaults(defineProps<Props>(), {
modelValue: null,
labelValue: '',
placeholder: '请选择',
disabled: false,
loading: false,
params: () => ({}),
valueField: 'ACCOUNT_ID',
labelField: 'USER_NAME',
searchField: 'q',
popoverWidth: 500,
showPagination: true,
immediate: true,
strict: false,
showSearch: true,
showIndex: false,
searchValue: 'label',
filterData: true
})
const emit = defineEmits(['update:modelValue', 'update:labelValue', 'change', 'select'])
const showPopover = ref(false)
const searchKeyword = ref('')
const throttledKeyword = ref('')
const tableRef = ref<any>(null)
const searchInputRef = ref<any>(null)
const selectedRow = ref<any>(null)
const displayLabel = ref(props.labelValue)
const preloadedList = ref<any[]>([])
/** 值的标准化处理 */
const normalizeValue = (v: any) => {
if (v === null || v === undefined || v === '') return null
return props.strict ? v : String(v)
}
watch(
searchKeyword,
debounce((val) => {
throttledKeyword.value = val
}, 300)
)
/** 组合搜索参数 */
const combinedParams = computed(() => {
const p: Record<string, any> = { ...props.params }
if (props.showSearch) {
p[props.searchField] = throttledKeyword.value
}
return p
})
watch(
() => props.labelValue,
(val) => {
displayLabel.value = val
}
)
const updateSelectedState = (list: any[], val: any) => {
const match = list.find((r: any) => normalizeValue(r[props.valueField]) === normalizeValue(val))
if (match) {
displayLabel.value = match[props.labelField] || match['REAL_NAME'] || match['USER_NAME']
selectedRow.value = match
emit('update:labelValue', displayLabel.value)
} else {
displayLabel.value = String(val)
}
}
const filterNullAndDuplicate = (list: any[]) => {
if (!Array.isArray(list)) return []
const uniqueData: any[] = []
const seen = new Set()
list.forEach((item) => {
const val = item[props.valueField]
if (val !== undefined && val !== null && String(val).trim() !== '' && !seen.has(val)) {
seen.add(val)
uniqueData.push(item)
}
})
return uniqueData
}
const handleTableResponse = (data: any[]) => {
return props.filterData ? filterNullAndDuplicate(data) : data
}
const fetchPreloadedData = async () => {
if (!props.api) return
try {
const payload: Record<string, any> = {
...props.params,
page: 1,
pageSize: 1000
}
if (props.showSearch) {
payload[props.searchField] = ''
}
const res = await service.post(props.api, payload)
if (res.code === 200) {
const list = Array.isArray(res.data) ? res.data : res.data?.rows || res.data?.data || []
const filteredList = props.filterData ? filterNullAndDuplicate(list) : list
preloadedList.value = filteredList
if (props.modelValue && !displayLabel.value) {
updateSelectedState(filteredList, props.modelValue)
}
}
} catch (e) {
console.error('CommonTableSelect preload failed:', e)
}
}
onMounted(() => {
fetchPreloadedData()
})
watch(
() => props.modelValue,
(val) => {
if (val === null || val === undefined || val === '') {
displayLabel.value = ''
selectedRow.value = null
} else if (preloadedList.value.length > 0) {
updateSelectedState(preloadedList.value, val)
} else {
fetchPreloadedData()
}
}
)
/** 处理行选中 (由用户点击触发) */
const onRowSelect = (row: any) => {
if (row) {
const val = normalizeValue(row[props.valueField])
const label = row[props.labelField] || row['REAL_NAME'] || row['USER_NAME']
displayLabel.value = label
emit('update:modelValue', val)
emit('update:labelValue', label)
emit('change', val, row)
emit('select', row)
showPopover.value = false
}
}
const onTableSuccess = (res: any) => {
if (props.modelValue && !selectedRow.value) {
const list = Array.isArray(res.data) ? res.data : res.data?.rows || res.data?.data || []
const match = list.find((r: any) => normalizeValue(r[props.valueField]) === normalizeValue(props.modelValue))
if (match) selectedRow.value = match
}
}
const onPopoverShow = (show: boolean) => {
if (show) {
selectedRow.value = null
const kw = props.showSearch ? (props.searchValue === 'value' ? props.modelValue : displayLabel.value) || '' : ''
searchKeyword.value = String(kw)
throttledKeyword.value = String(kw)
nextTick(() => {
tableRef.value?.reSearch()
if (props.showSearch) {
searchInputRef.value?.focus()
}
})
}
}
const handleSearch = () => {
throttledKeyword.value = searchKeyword.value
tableRef.value?.reSearch()
}
const onClear = () => {
selectedRow.value = null
displayLabel.value = ''
emit('update:modelValue', null)
emit('update:labelValue', '')
emit('change', null, null)
}
</script>
<style scoped>
:deep(.n-popover-shared) {
padding: 0;
}
</style>
<template>
<n-tag
v-if="(currentValue !== null && currentValue !== undefined && currentLabel) || $slots.default"
:type="tagType"
size="small"
v-bind="$attrs"
:round="round"
:bordered="bordered"
:style="customStyle"
:loading="internalLoading"
>
<slot v-if="$slots.default"></slot>
<template v-else>{{ currentLabel }}</template>
</n-tag>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
const props = defineProps({
/** 当前值 */
value: {
type: [String, Number, Boolean] as PropType<string | number | boolean | null | undefined>,
default: null
},
/** 接口 URL */
api: {
type: String,
default: ''
},
/** 接口参数 */
params: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
/** 静态选项 */
options: {
type: Array as PropType<any[]>,
default: null
},
/**
* 状态颜色转换逻辑
* 1. 提供对象:{ '0': 'success', '1': 'error' }
* 2. 提供函数:(val) => val === 0 ? 'success' : 'error'
* 3. 默认值处理:若不提供,且值为 0/1/true/false,则自动处理 (0/true -> success, 其他 -> error)
*/
typeMap: {
type: [Object, Function] as PropType<Record<string, string> | ((val: any) => string)>,
default: null
},
/** 默认 Tag 类型 */
type: {
type: String as PropType<'default' | 'primary' | 'info' | 'success' | 'warning' | 'error'>,
default: null
},
defaultType: {
type: String as PropType<'default' | 'primary' | 'info' | 'success' | 'warning' | 'error'>,
default: 'default'
},
/** 是否开启语义化自动着色(针对 0/1/true/false) */
autoColor: {
type: Boolean,
default: false
},
/** 是否圆角 */
round: {
type: Boolean,
default: false
},
/** 是否有边框 */
bordered: {
type: Boolean,
default: false
},
/** 严格模式:默认不开启(不开启时自动转字符串比对,不区分 1 和 '1') */
strict: {
type: Boolean,
default: false
}
})
const apiOptions = ref<any[]>([])
const internalLoading = ref(false)
/**
* 值的标准化处理
*/
const normalizeValue = (v: any) => {
if (v === null || v === undefined || v === '') return null
return props.strict ? v : String(v)
}
const currentValue = computed(() => {
return normalizeValue(props.value)
})
const fetchApiOptions = async () => {
if (!props.api) return
internalLoading.value = true
try {
const res = await requestListData(props.api, props.params)
if (res.success) {
apiOptions.value = res.data
}
} catch (e) {
console.error('Fetch tag options failed:', e)
apiOptions.value = []
} finally {
internalLoading.value = false
}
}
watch(
() => props.api,
(val) => {
if (val) fetchApiOptions()
},
{ immediate: true }
)
watch(
() => JSON.stringify(props.params),
(newVal, oldVal) => {
if (newVal !== oldVal && props.api) {
fetchApiOptions()
}
}
)
const currentOption = computed(() => {
if (currentValue.value === null) return null
let list: any[] = []
if (props.options) {
list = props.options
} else if (props.api) {
list = apiOptions.value
}
if (list.length > 0) {
return list.find((opt) => {
const val = opt.VALUE !== undefined ? opt.VALUE : (opt.id ?? opt.value ?? opt.PKID)
return normalizeValue(val) === currentValue.value
})
}
return null
})
const currentLabel = computed(() => {
if (currentOption.value) {
return (
currentOption.value.TEXT ||
currentOption.value.name ||
currentOption.value.label ||
currentOption.value.REAL_NAME ||
currentOption.value.MENU_NAME ||
currentOption.value.title
)
}
// 如果没有找到选项,直接返回原值(或根据业务需求返回空)
return String(props.value ?? '')
})
const tagType = computed(() => {
let result: 'default' | 'primary' | 'info' | 'success' | 'warning' | 'error' = (props.type || props.defaultType || 'default') as any
const valNorm = currentValue.value
if (props.typeMap) {
if (typeof props.typeMap === 'function') {
result = props.typeMap(props.value) as any
} else {
// 在 Map 中查找时需要确保键名匹配
const map = props.typeMap as any
result = (map[valNorm!] || props.defaultType) as any
}
} else if (props.autoColor) {
// 只有开启了 autoColor 才进行内置布尔类型的视觉感知
if (valNorm === '0' || valNorm === 0 || valNorm === 'true' || valNorm === true) {
result = 'success'
} else if (valNorm === '1' || valNorm === 1 || valNorm === 'false' || valNorm === false) {
result = 'error'
}
}
return result
})
const customStyle = computed(() => {
// 默认最小宽度以保证“是/否”等短文本对齐美观
const label = currentLabel.value
const styles: any = {
minWidth: label && String(label).length <= 2 ? '45px' : 'auto',
maxWidth: '100%',
justifyContent: 'center'
}
return styles
})
</script>
<style scoped>
:deep(.n-tag__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
</style>
<template>
<n-tree-select
ref="treeSelectInst"
v-model:value="computedValue"
:options="computedOptions"
:loading="isLoading"
:label-field="labelField"
:key-field="keyField"
:multiple="multiple"
:clearable="clearable"
:filterable="filterable"
:default-expand-all="defaultExpandAll"
v-model:show="showMenu"
v-bind="$attrs"
/>
</template>
<script setup lang="ts">
import { service } from '@/api/index'
import type { PropType, ComponentPublicInstance } from 'vue'
const props = defineProps({
/** 绑定的值 (v-model) */
value: {
type: [String, Number, Array] as PropType<string | number | any[] | null | undefined>,
default: null
},
modelValue: {
type: [String, Number, Array] as PropType<string | number | any[] | null | undefined>,
default: null
},
/** 接口 URL */
api: {
type: String,
default: ''
},
/** 接口参数 */
params: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
/** 静态选项 */
options: {
type: Array as PropType<any[]>,
default: null
},
/** 根节点 ID (默认 '0') */
rootId: {
type: [String, Number],
default: '0'
},
/** 是否包含一个虚拟的顶级节点 */
includeRoot: {
type: Boolean,
default: false
},
/** 虚拟顶级节点的文本 */
rootLabel: {
type: String,
default: '顶级'
},
/** 是否多选 */
multiple: {
type: Boolean,
default: false
},
/** 是否可清空 */
clearable: {
type: Boolean,
default: true
},
/** 是否可搜索 */
filterable: {
type: Boolean,
default: true
},
/** 默认展开所有 */
defaultExpandAll: {
type: Boolean,
default: true
},
/** 严格模式:默认不开启(不开启时自动转字符串比对,不区分 1 和 '1') */
strict: {
type: Boolean,
default: false
},
/** 外部传入的加载状态 */
loading: {
type: Boolean,
default: false
},
/** 标签字段名,组件会根据数据自动适配,也可显式指定 */
labelField: {
type: String,
default: 'label'
},
/** 键值字段名,组件会根据数据自动适配,也可显式指定 */
keyField: {
type: String,
default: 'value'
}
})
const emit = defineEmits(['update:value', 'update:modelValue', 'change'])
const treeSelectInst = ref<(ComponentPublicInstance & { scrollTo: (options: { key: string | number }) => void }) | null>(null)
const showMenu = ref(false)
const apiData = ref<any>(null)
const internalLoading = ref(false)
const isLoading = computed(() => internalLoading.value || props.loading)
/**
* 值的标准化处理
*/
const normalizeValue = (v: any) => {
if (v === null || v === undefined || v === '') return null
return props.strict ? v : String(v)
}
/**
* 兼容 v-model 和 v-model:value
*/
const computedValue = computed<any>({
get: () => {
const val = props.modelValue ?? props.value
if (Array.isArray(val)) {
return val.map(normalizeValue).filter((v) => v !== null)
}
return normalizeValue(val)
},
set: (val: any) => {
emit('update:value', val)
emit('update:modelValue', val)
emit('change', val)
}
})
/**
* 构建树形结构的通用函数
*/
const buildTree = (data: any, rootId: string | number) => {
if (!data) return []
// 所有的比对逻辑都基于标准化后的值
const rootIdNorm = normalizeValue(rootId)
// 递归辅助函数
const build = (pid: any, source: any): any[] => {
let children: any[] = []
const pidNorm = normalizeValue(pid)
// 情况 A: 数据源是 Map 结构 (以父级 ID 为 Key,如 MENU_TREE_DATA)
if (typeof source === 'object' && !Array.isArray(source)) {
children = source[pidNorm] || []
}
// 情况 B: 数据源是扁平数组
else if (Array.isArray(source)) {
children = source.filter((item: any) => {
const itemPid =
item.PID !== undefined ? item.PID : item.pid !== undefined ? item.pid : item.parentId !== undefined ? item.parentId : ''
return normalizeValue(itemPid) === pidNorm
})
}
return children.map((item: any) => {
const node: any = { ...item }
// 自动适配 label/value
const label = item.MENU_NAME || item.TEXT || item.text || item.label || item.name || item.title
const rawValue = item.PKID !== undefined ? item.PKID : item.id !== undefined ? item.id : item.value !== undefined ? item.value : ''
const value = normalizeValue(rawValue)
if (!node.label) node.label = label
node.value = value // 强制覆盖以便组件识别
// 递归构建子节点
const sub = build(value, source)
if (sub.length > 0) {
node.children = sub
}
return node
})
}
return build(rootIdNorm, data)
}
/**
* 递归格式化已有的树形结构,补充 label 和 value 属性
*/
const formatTreeNodes = (nodes: any[]): any[] => {
if (!nodes) return []
return nodes.map((item: any) => {
const node = { ...item }
// 自动适配 label
const label = item.label || item.MENU_NAME || item.TEXT || item.text || item.name || item.title
// 自动适配 value
const rawValue = item.value !== undefined ? item.value : item.PKID !== undefined ? item.PKID : item.id !== undefined ? item.id : ''
const value = normalizeValue(rawValue)
if (node.label === undefined) node.label = label
if (node.value === undefined) node.value = value
if (item.children && item.children.length > 0) {
node.children = formatTreeNodes(item.children)
}
return node
})
}
const computedOptions = computed(() => {
let nodes: any[] = []
if (props.options) {
// 如果提供了静态 options,假设其已经是树形结构或者需要构建
// 这里为了灵活性,如果是扁平数组则构建,否则直接返回
nodes =
Array.isArray(props.options) && props.options.some((o) => o.pid !== undefined || o.PID !== undefined)
? buildTree(props.options, props.rootId)
: formatTreeNodes(props.options)
} else if (apiData.value) {
nodes = buildTree(apiData.value, props.rootId)
}
if (props.includeRoot) {
return [
{
[props.labelField]: props.rootLabel,
[props.keyField]: String(props.rootId),
children: nodes
}
]
}
return nodes
})
/**
* 获取 API 数据
*/
const fetchApiData = async () => {
if (!props.api) return
internalLoading.value = true
try {
// 直接使用 service.post 以获取完整的 data 结构 (支持 map 类型)
const res = await service.post(props.api, props.params)
if (res.code === 200) {
apiData.value = res.data
}
} catch (e) {
console.error('[CommonTreeSelect] Fetch failed:', e)
apiData.value = null
} finally {
internalLoading.value = false
}
}
// 监听 API 及其参数变化
watch(
() => props.api,
(val) => {
if (val) fetchApiData()
},
{ immediate: true }
)
watch(
() => JSON.stringify(props.params),
(newVal, oldVal) => {
if (newVal !== oldVal && props.api) {
fetchApiData()
}
}
)
/**
* 自动滚动到选中值 (当值变化或菜单打开时触发)
*/
watch(
[computedValue, computedOptions, showMenu],
() => {
if (!showMenu.value) return // 只有打开时才滚动
const val = Array.isArray(computedValue.value) ? computedValue.value[0] : computedValue.value
if (val !== null && val !== undefined && treeSelectInst.value) {
// 延迟执行确保内部 Tree 已经渲染
nextTick(() => {
const inst = treeSelectInst.value as any
// Naive UI 的 n-tree-select 通常通过 treeInstRef 暴露内部的 tree 实例
const targetScrollTo = inst.scrollTo || inst.treeInstRef?.scrollTo
if (typeof targetScrollTo === 'function') {
targetScrollTo({ key: val })
}
})
}
},
{ immediate: true, deep: true }
)
/**
* 清除内部数据(供外部调用)
*/
const clearData = () => {
apiData.value = null
}
defineExpose({
clearData,
fetchApiData
})
</script>
<style>
.n-tree-select-menu {
min-width: max-content !important;
max-width: 600px !important;
}
.n-tree-select-menu .n-tree-node-content {
white-space: nowrap !important;
}
.n-tree-select-menu .n-tree {
min-width: max-content !important;
}
</style>
<style scoped></style>
<template>
<CommonModal v-model="show" :title="title" style="width: 500px">
<div class="p-6">
<n-upload
v-model:file-list="fileList"
:default-upload="false"
:max="1"
:accept="acceptVal"
action="#"
@before-upload="beforeUpload"
@remove="handleRemove"
>
<n-upload-dragger v-if="fileList.length === 0">
<div class="mb-3">
<n-icon size="48" :depth="3">
<CloudUploadOutline />
</n-icon>
</div>
<n-text style="font-size: 16px">点击或将文件拖拽到这里上传</n-text>
<n-p depth="3" style="margin: 8px 0 0 0">
{{ acceptVal && acceptVal.includes('image') ? '支持上传图片格式的附件' : '支持上传各种格式的附件' }}
</n-p>
</n-upload-dragger>
</n-upload>
<n-progress v-if="uploading" type="line" :percentage="uploadProgress" :indicator-placement="'inside'" processing class="mt-4" />
</div>
<template #footer>
<div class="flex justify-end gap-2">
<CommonButton @click="show = false">取消</CommonButton>
<CommonButton type="primary" :loading="uploading" :disabled="fileList.length === 0" @click="handleConfirm">开始上传</CommonButton>
</div>
</template>
</CommonModal>
</template>
<script setup lang="ts">
import { CloudUploadOutline } from '@vicons/ionicons5'
import type { UploadFileInfo } from 'naive-ui'
import { service } from '@/api/index'
export interface UploadOptions {
/** 弹窗标题 */
title?: string
/** 上传接口地址 (FunctionCode), 不传则默认为 /v1/plugins/ATTACHMENT_UPLOAD */
api?: string
/** 允许选择的文件类型,例如 'image/*' */
accept?: string
/** 接口所需的其它业务参数 */
data?: Record<string, any>
/** 上传成功后的回调 */
onSuccess?: (res?: any) => void
}
const show = ref(false)
const uploading = ref(false)
const uploadProgress = ref(0)
const title = ref('上传附件')
const acceptVal = ref('')
const fileList = ref<UploadFileInfo[]>([])
const context = ref<UploadOptions | null>(null)
let progressTimer: any = null
const open = (options: UploadOptions) => {
context.value = options
if (options.title) title.value = options.title
acceptVal.value = options.accept || ''
fileList.value = []
uploadProgress.value = 0
uploading.value = false
show.value = true
}
const beforeUpload = () => {
// 默认不限制,子类业务如果有特定格式要求,可通过组件 prop 扩展
return true
}
const handleRemove = () => {
uploadProgress.value = 0
}
const handleConfirm = async () => {
const ctx = context.value
if (fileList.value.length === 0 || !ctx) return
const uploadApi = ctx.api || '/v1/plugins/ATTACHMENT_UPLOAD'
uploading.value = true
uploadProgress.value = 0
// 模拟进度
progressTimer = setInterval(() => {
if (uploadProgress.value < 90) {
uploadProgress.value += Math.floor(Math.random() * 5) + 1
}
}, 200)
try {
const formData = new FormData()
const file = fileList.value[0]?.file
if (file) {
formData.append('file', file)
}
// 合并业务参数(cat, sourceId 等)
if (ctx.data) {
Object.entries(ctx.data).forEach(([key, val]) => {
formData.append(key, String(val))
})
}
const res = await service.post(uploadApi, formData, { showLoading: false })
if (res.code === 200) {
uploadProgress.value = 100
window.$message.success('上传成功')
ctx.onSuccess?.(res)
setTimeout(() => {
show.value = false
}, 500)
}
} catch (e: any) {
console.error('Upload failed', e)
uploadProgress.value = 0
} finally {
uploading.value = false
if (progressTimer) clearInterval(progressTimer)
}
}
defineExpose({ open })
</script>
import type { GlobalThemeOverrides } from 'naive-ui'
export const lightThemeConfig: GlobalThemeOverrides['common'] = {
baseColor: '#FFF',
primaryColor: '#165DFF',
primaryColorHover: '#4080FF',
primaryColorPressed: '#0E42D2',
primaryColorSuppl: '#94BFFF',
infoColor: '#2080f0',
infoColorHover: '#4098fc',
infoColorPressed: '#1060c9',
infoColorSuppl: '#4098fc',
successColor: '#00B42A',
successColorHover: '#23C343',
successColorPressed: '#009A29',
successColorSuppl: '#7BE188',
warningColor: '#FF7D00',
warningColorHover: '#FF9A2E',
warningColorPressed: '#D25F00',
warningColorSuppl: '#FFCF8B',
errorColor: '#F53F3F',
errorColorHover: '#F76560',
errorColorPressed: '#CB2634',
errorColorSuppl: '#FBACA3',
textColorBase: '#000',
textColor1: '#1D2129',
textColor2: '#4E5969',
textColor3: '#86909C',
textColorDisabled: '#C9CDD4',
placeholderColor: 'rgba(194, 194, 194, 1)',
placeholderColorDisabled: 'rgba(209, 209, 209, 1)',
iconColor: 'rgba(194, 194, 194, 1)',
iconColorHover: 'rgba(146, 146, 146, 1)',
iconColorPressed: 'rgba(175, 175, 175, 1)',
iconColorDisabled: 'rgba(209, 209, 209, 1)',
dividerColor: '#e8eaed',
borderColor: '#dee2e6',
closeIconColor: 'rgba(102, 102, 102, 1)',
closeIconColorHover: 'rgba(102, 102, 102, 1)',
closeIconColorPressed: 'rgba(102, 102, 102, 1)',
closeColorHover: 'rgba(0, 0, 0, .09)',
closeColorPressed: 'rgba(0, 0, 0, .13)',
clearColor: 'rgba(194, 194, 194, 1)',
clearColorHover: 'rgba(146, 146, 146, 1)',
clearColorPressed: 'rgba(175, 175, 175, 1)',
scrollbarColor: 'rgba(0, 0, 0, 0.25)',
scrollbarColorHover: 'rgba(0, 0, 0, 0.4)',
progressRailColor: 'rgba(235, 235, 235, 1)',
railColor: 'rgb(219, 219, 223)',
popoverColor: '#fff',
tableColor: '#fff',
cardColor: '#fff',
modalColor: '#fff',
bodyColor: '#f5f7fa',
tagColor: '#eee',
avatarColor: 'rgba(204, 204, 204, 1)',
invertedColor: 'rgb(0, 20, 40)',
inputColor: 'rgba(255, 255, 255, 1)',
codeColor: 'rgb(244, 244, 248)',
tabColor: 'rgb(247, 247, 250)',
actionColor: 'rgb(250, 250, 252)',
tableHeaderColor: 'rgb(250, 250, 252)',
hoverColor: 'rgb(243, 243, 245)',
tableColorHover: 'rgba(0, 0, 100, 0.03)',
tableColorStriped: 'rgba(0, 0, 100, 0.02)',
pressedColor: 'rgb(237, 237, 239)',
opacityDisabled: '0.5',
inputColorDisabled: 'rgb(250, 250, 252)',
buttonColor2: 'rgba(46, 51, 56, .05)',
buttonColor2Hover: 'rgba(46, 51, 56, .09)',
buttonColor2Pressed: 'rgba(46, 51, 56, .13)',
// 主色
primary6: '#165DFF',
primary5: '#4080FF',
primary7: '#000D4D',
primary4: '#6AA1FF',
primary3: '#94BFFF',
primary2: '#BEDAFF',
primary1: '#E8F3FF',
// 成功色
success6: '#00B42A',
success5: '#23C343',
success7: '#004D1C',
success4: '#4CD263',
success3: '#7BE188',
success2: '#AFF0B5',
success1: '#E8FFEA',
// 警示色
warning6: '#F77234',
warning5: '#F99057',
warning7: '#CC5120',
warning4: '#F99057',
warning3: '#FCC59F',
warning2: '#FDDDC3',
warning1: '#FFF3E8',
// 错误色
danger6: '#F53F3F',
danger5: '#F76560',
danger7: '#CB272D',
danger4: '#F98981',
danger3: '#FBACA3',
danger2: '#FDCDC5',
danger1: '#FFECE8',
// 链接色
link6: '#3491FA',
link5: '#57A9FB',
link7: '#206CCF',
link4: '#7BC0FC',
link3: '#9FD4FD',
link2: '#C3E7FE',
link1: '#E8F7FF',
// 边框颜色
colorBorder1: '#f2f3f5',
colorBorder2: '#e5e6eb',
colorBorder3: '#c9cdd4',
colorBorder4: '#86909c',
// 填充颜色
colorFill1: '#f7f8fa',
colorFill2: '#f2f3f5',
colorFill3: '#e5e6eb',
colorFill4: '#c9cdd4',
// 文字颜色
colorText1: '#1d2129',
colorText2: '#4e5969',
colorText3: '#86909c',
colorText4: '#c9cdd4',
// 背景颜色
colorBg1: '#ffffff',
colorBg2: '#ffffff',
colorBg3: '#ffffff',
colorBg4: '#ffffff',
colorBg5: '#ffffff'
}
export const darkThemeConfig: GlobalThemeOverrides['common'] = {
baseColor: '#000',
primaryColor: '#3c7eff',
primaryColorHover: '#7fe7c4',
primaryColorPressed: '#5acea7',
primaryColorSuppl: 'rgb(42, 148, 125)',
infoColor: '#70c0e8',
infoColorHover: '#8acbec',
infoColorPressed: '#66afd3',
infoColorSuppl: 'rgb(56, 137, 197)',
successColor: '#3c7eff',
successColorHover: '#7fe7c4',
successColorPressed: '#5acea7',
successColorSuppl: 'rgb(42, 148, 125)',
warningColor: '#f2c97d',
warningColorHover: '#f5d599',
warningColorPressed: '#e6c260',
warningColorSuppl: 'rgb(240, 138, 0)',
errorColor: '#e88080',
errorColorHover: '#e98b8b',
errorColorPressed: '#e57272',
errorColorSuppl: 'rgb(208, 58, 82)',
textColorBase: '#fff',
textColor1: 'rgba(255, 255, 255, 0.9)',
textColor2: 'rgba(255, 255, 255, 0.82)',
textColor3: 'rgba(255, 255, 255, 0.52)',
textColorDisabled: 'rgba(255, 255, 255, 0.38)',
placeholderColor: 'rgba(255, 255, 255, 0.38)',
placeholderColorDisabled: 'rgba(255, 255, 255, 0.28)',
iconColor: 'rgba(255, 255, 255, 0.38)',
iconColorDisabled: 'rgba(255, 255, 255, 0.28)',
iconColorHover: 'rgba(255, 255, 255, 0.475)',
iconColorPressed: 'rgba(255, 255, 255, 0.30400000000000005)',
dividerColor: 'rgba(255, 255, 255, 0.09)',
borderColor: 'rgba(255, 255, 255, 0.24)',
closeIconColorHover: 'rgba(255, 255, 255, 0.52)',
closeIconColor: 'rgba(255, 255, 255, 0.52)',
closeIconColorPressed: 'rgba(255, 255, 255, 0.52)',
closeColorHover: 'rgba(255, 255, 255, .12)',
closeColorPressed: 'rgba(255, 255, 255, .08)',
clearColor: 'rgba(255, 255, 255, 0.38)',
clearColorHover: 'rgba(255, 255, 255, 0.48)',
clearColorPressed: 'rgba(255, 255, 255, 0.3)',
scrollbarColor: 'rgba(255, 255, 255, 0.2)',
scrollbarColorHover: 'rgba(255, 255, 255, 0.3)',
progressRailColor: 'rgba(255, 255, 255, 0.12)',
railColor: 'rgba(255, 255, 255, 0.2)',
popoverColor: 'rgb(72, 72, 78)',
tableColor: 'rgb(24, 24, 28)',
cardColor: 'rgb(24, 24, 28)',
modalColor: 'rgb(44, 44, 50)',
bodyColor: 'rgb(16, 16, 20)',
tagColor: 'rgba(51, 51, 51, 1)',
avatarColor: 'rgba(255, 255, 255, 0.18)',
invertedColor: '#000',
inputColor: 'rgba(255, 255, 255, 0.1)',
codeColor: 'rgba(255, 255, 255, 0.12)',
tabColor: 'rgba(255, 255, 255, 0.04)',
actionColor: 'rgba(255, 255, 255, 0.06)',
tableHeaderColor: 'rgba(255, 255, 255, 0.06)',
hoverColor: 'rgba(255, 255, 255, 0.09)',
tableColorHover: 'rgba(255, 255, 255, 0.06)',
tableColorStriped: 'rgba(255, 255, 255, 0.05)',
pressedColor: 'rgba(255, 255, 255, 0.05)',
opacityDisabled: '0.38',
inputColorDisabled: 'rgba(255, 255, 255, 0.06)',
buttonColor2: 'rgba(255, 255, 255, .08)',
buttonColor2Hover: 'rgba(255, 255, 255, .12)',
buttonColor2Pressed: 'rgba(255, 255, 255, .08)',
// 主色
primary6: '#3c7eff',
primary5: '#306fff',
primary7: '#689fff',
primary4: '#1d4dd2',
primary3: '#0e32a6',
primary2: '#041b79',
primary1: '#000d4d',
// 成功色
success6: '#27c346',
success5: '#1db440',
success7: '#50d266',
success4: '#129a37',
success3: '#0a802d',
success2: '#046625',
success1: '#004d1c',
// 警示色
warning6: '#ff9626',
warning5: '#ff8d1f',
warning7: '#ffb357',
warning4: '#d26913',
warning3: '#a64b0a',
warning2: '#793004',
warning1: '#4d1b00',
// 错误色
danger6: '#f76965',
danger5: '#f54e4e',
danger7: '#f98d86',
danger4: '#cb2e34',
danger3: '#a1161f',
danger2: '#770611',
danger1: '#4d000a',
// 链接色
link6: '#3c7eff',
link5: '#306fff',
link7: '#689fff',
link4: '#1d4dd2',
link3: '#0e32a6',
link2: '#041b79',
link1: '#000d4d',
// 边框颜色
colorBorder1: '#2e2e30',
colorBorder2: '#484849',
colorBorder3: '#5f5f60',
colorBorder4: '#929293',
// 填充颜色
colorFill1: '#17171a',
colorFill2: '#2e2e30',
colorFill3: '#484849',
colorFill4: '#5f5f60',
// 文字颜色
colorText1: '#f6f6f6',
colorText2: '#c5c5c5',
colorText3: '#929293',
colorText4: '#5f5f60',
// 背景颜色
colorBg1: '#17171A',
colorBg2: '#232324',
colorBg3: '#2A2A2B',
colorBg4: '#313132',
colorBg5: '#373739'
}
import { useAppStore } from '@/store/app'
import { createDiscreteApi, darkTheme } from 'naive-ui'
import type { DialogOptions } from 'naive-ui'
/**
* 通用 Dialog 封装
* 1. 支持标准 Naive UI 的对象调用方式(推荐): dialog.warning({ title: '', content: '' })
* 2. 也兼容传统的 (title, content, options) 传参方式
* 3. 自动转换为 Promise 风格
*/
const createDialog = (type: 'info' | 'error' | 'success' | 'warning') => {
return function (titleOrOptions: string | DialogOptions, content?: string, config: DialogOptions = {}): Promise<boolean> {
return new Promise((resolve, reject) => {
const appStore = useAppStore()
const { dialog } = createDiscreteApi(['dialog'], {
configProviderProps: { theme: appStore.isDark ? darkTheme : null }
})
let finalOptions: DialogOptions = {}
if (typeof titleOrOptions === 'object') {
// 情况 1: 对象传参
finalOptions = { ...titleOrOptions }
} else {
// 情况 2: (title, content, options) 传参
finalOptions = {
title: titleOrOptions,
content: content,
...config
}
}
// 业务默认配置
const businessConfig: DialogOptions = {
draggable: true,
onPositiveClick: () => resolve(true),
onNegativeClick: () => reject(false),
onClose: () => reject(false),
onEsc: () => reject(false),
onMaskClick: () => reject(false)
}
if (type === 'warning') {
businessConfig.positiveText = '确定'
businessConfig.negativeText = '取消'
}
// 合并配置: 业务默认 < 传入配置
dialog[type]({
...businessConfig,
...finalOptions
})
})
}
}
export const useDialog = () => {
return {
info: createDialog('info'),
error: createDialog('error'),
success: createDialog('success'),
warning: createDialog('warning')
}
}
import mitt from 'mitt'
import { onBeforeUnmount } from 'vue'
type Fn = (...args: any[]) => void
interface Option {
name: string // 事件名称
callback: Fn // 回调
}
const emitter = mitt<any>()
export const useEventBus = (option?: Option) => {
if (option) {
const { name, callback } = option
// 注册事件监听器
emitter.on(name, callback)
// 组件卸载时注销事件监听器
onBeforeUnmount(() => {
emitter.off(name, callback)
})
}
// 返回事件总线的方法
return {
on: emitter.on,
off: emitter.off,
emit: emitter.emit,
all: emitter.all
}
}
import { useAppStore } from '@/store/app'
import { createDiscreteApi, darkTheme } from 'naive-ui'
export const useMessage = () => {
return {
// 消息提示
info(content: string, config = {}) {
const appStore = useAppStore()
const { message } = createDiscreteApi(['message'], {
configProviderProps: { theme: appStore.isDark ? darkTheme : null }
})
message.info(content, config)
},
// 错误消息
error(content: string, config = {}) {
const appStore = useAppStore()
const { message } = createDiscreteApi(['message'], {
configProviderProps: { theme: appStore.isDark ? darkTheme : null }
})
message.error(content, config)
},
// 成功消息
success(content: string, config = {}) {
const appStore = useAppStore()
const { message } = createDiscreteApi(['message'], {
configProviderProps: { theme: appStore.isDark ? darkTheme : null }
})
message.success(content, config)
},
// 警告消息
warning(content: string, config = {}) {
const appStore = useAppStore()
const { message } = createDiscreteApi(['message'], {
configProviderProps: { theme: appStore.isDark ? darkTheme : null }
})
message.warning(content, config)
}
}
}
import { useAppStore } from '@/store/app'
import { createDiscreteApi, darkTheme } from 'naive-ui'
export const useNotification = () => {
return {
// 通知提示
info(content: string, config = {}) {
const appStore = useAppStore()
const { notification } = createDiscreteApi(['notification'], {
configProviderProps: { theme: appStore.isDark ? darkTheme : null }
})
notification.info({
content,
...config
})
},
// 错误通知
error(content: string, config = {}) {
const appStore = useAppStore()
const { notification } = createDiscreteApi(['notification'], {
configProviderProps: { theme: appStore.isDark ? darkTheme : null }
})
notification.error({
content,
...config
})
},
// 成功通知
success(content: string, config = {}) {
const appStore = useAppStore()
const { notification } = createDiscreteApi(['notification'], {
configProviderProps: { theme: appStore.isDark ? darkTheme : null }
})
notification.success({
content,
...config
})
},
// 警告通知
warning(content: string, config = {}) {
const appStore = useAppStore()
const { notification } = createDiscreteApi(['notification'], {
configProviderProps: { theme: appStore.isDark ? darkTheme : null }
})
notification.warning({
content,
...config
})
}
}
}
/**
* 统一列表页面搜索逻辑 Hook
*/
export function useTableSearch<T extends object>(
defaultForm: T,
tableRef?: any,
options?: {
exportModalRef?: any
importModalRef?: any
fileName?: string
functionCode?: string
importTitle?: string
importApi?: string
templateName?: string
templateApi?: string
templateTitle?: string
}
) {
// UI 绑定的表单状态
const searchForm = reactive({ ...defaultForm })
// 实际触发表格请求的参数状态
const tableParams = reactive({ ...defaultForm })
const handleSearch = () => {
// 判断参数是否发生实质性变化
const isChanged = JSON.stringify(searchForm) !== JSON.stringify(tableParams)
Object.assign(tableParams, searchForm)
if (isChanged) {
// 参数变了,必须跳回第一页(Search 行为)
tableRef?.value?.reSearch()
} else {
// 参数没变,只是刷新当前页数据(Refresh 行为)
tableRef?.value?.refresh()
}
}
const resetSearch = () => {
// 重置 UI 表单
Object.assign(searchForm, defaultForm)
// 重置请求参数
Object.assign(tableParams, defaultForm)
// 重新查询
tableRef.value?.reSearch()
}
/** 统一处理导出逻辑 */
const handleExport = (overrideFileName?: string, overrideFunctionCode?: string) => {
// 核心修复:如果第一个参数是事件对象(来自 @click="handleExport"),则忽略它作为文件名
const isString = typeof overrideFileName === 'string'
const fileName = (isString ? overrideFileName : options?.fileName) || '导出文件'
const functionCode = overrideFunctionCode || options?.functionCode
// 获取当前表格的页码和页大小
const page = tableRef?.value?.currentPage
const rows = tableRef?.value?.currentPageSize
const modal = options?.exportModalRef?.value || window.$exportModal
if (modal) {
modal.open({
fileName,
functionCode,
params: {
...tableParams,
page,
rows
}
})
} else {
console.warn('useTableSearch: Global $exportModal or local exportModalRef is not provided.')
}
}
/** 统一处理导入逻辑 */
const handleImport = () => {
const modal = options?.importModalRef?.value || window.$importModal
if (modal) {
modal.open({
title: options?.importTitle,
api: options?.importApi,
templateName: options?.templateName,
templateApi: options?.templateApi,
templateTitle: options?.templateTitle,
onSuccess: () => tableRef.value?.refresh()
})
} else {
console.warn('useTableSearch: Global $importModal or local importModalRef is not provided.')
}
}
return {
searchForm,
tableParams,
handleSearch,
resetSearch,
handleExport,
handleImport
}
}
<template>
<n-layout has-sider class="h-screen">
<n-layout-sider
collapse-mode="width"
:collapsed-width="64"
:width="240"
:collapsed="collapsed"
:native-scrollbar="true"
@collapse="collapsed = true"
@expand="collapsed = false"
bordered
class="sider-with-trigger"
>
<div class="sider-inner">
<!-- Logo 区 -->
<div class="h-16 px-4 flex items-center justify-center border-b flex-shrink-0" :style="{ borderColor: themeVars.dividerColor }">
<n-icon size="32" :color="themeVars.primaryColor"><airplane-outline /></n-icon>
<span
v-if="!collapsed"
class="text-xl font-black ml-3 truncate tracking-widest transition-colors"
:style="{ color: themeVars.textColor1 }"
>
AMRO 系统
</span>
</div>
<!-- 底部固定按钮 -->
<div class="sider-collapse-btn-wrap flex-shrink-0" :style="{ borderColor: themeVars.dividerColor }">
<div
class="sider-collapse-btn"
:style="{ borderColor: themeVars.dividerColor, background: themeVars.cardColor }"
@click="collapsed = !collapsed"
>
<n-icon size="18" :color="themeVars.textColor2">
<menu-outline />
</n-icon>
</div>
</div>
</div>
</n-layout-sider>
<n-layout class="flex flex-col h-screen overflow-hidden">
<n-layout-header
bordered
:position="appStore.fixedHeader ? 'absolute' : 'static'"
class="h-16 flex items-center justify-between px-6 flex-shrink-0 transition-colors"
:style="{ zIndex: appStore.fixedHeader ? 10 : 'auto' }"
>
<div class="flex items-center space-x-3">
<!-- 主题切换 -->
<CommonButton quaternary circle @click="appStore.isDark = !appStore.isDark">
<template #icon>
<n-icon>
<sunny-outline v-if="appStore.isDark" />
<moon-outline v-else />
</n-icon>
</template>
</CommonButton>
<!-- 偏好设置 -->
<SettingsDrawer />
</div>
</n-layout-header>
<n-layout-content
:content-style="{
paddingTop: appStore.contentCompact ? '6px' : '12px',
paddingBottom: appStore.contentCompact ? '6px' : '12px',
paddingLeft: appStore.contentCompact ? '6px' : '12px',
paddingRight: appStore.settingsPinned && appStore.settingsOpen ? '332px' : appStore.contentCompact ? '6px' : '12px',
display: 'flex',
flexDirection: 'column',
transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)'
}"
class="transition-colors"
:style="{ backgroundColor: themeVars.bodyColor }"
>
<router-view v-slot="{ Component, route }">
<transition :name="appStore.transitionName || 'none'" mode="out-in">
<keep-alive>
<component :is="Component" :key="route.fullPath" class="flex-1 flex flex-col min-h-0" />
</keep-alive>
</transition>
</router-view>
</n-layout-content>
</n-layout>
</n-layout>
<CommonImportModal ref="globalImportModalRef" />
<CommonExportModal ref="globalExportModalRef" />
<CommonUploadModal ref="globalUploadModalRef" />
<CommonDownloadModal ref="globalDownloadModalRef" />
</template>
<script setup lang="ts">
import { useAppStore } from '@/store/app/index'
import SettingsDrawer from './components/SettingsDrawer.vue'
import CommonDownloadModal from '@/components/CommonDownloadModal.vue'
import { AirplaneOutline, SunnyOutline, MoonOutline, MenuOutline } from '@vicons/ionicons5'
import router from '@/router/index'
const themeVars = useThemeVars()
const route = useRoute()
const appStore = useAppStore()
const globalImportModalRef = ref()
const globalExportModalRef = ref()
const globalAttachmentModalRef = ref()
const globalUploadModalRef = ref()
const globalPreviewModalRef = ref()
const globalDownloadModalRef = ref()
const collapsed = computed({
get: () => appStore.collapsed,
set: (val) => (appStore.collapsed = val)
})
const activeKey = ref(route.path)
// 菜单数据已通过 Pinia 持久化,无需在此重复获取
// 全局快捷键处理
const handleGlobalKeydown = (e: KeyboardEvent) => {
const ctrl = e.ctrlKey || e.metaKey
const alt = e.altKey
const shift = e.shiftKey
// Ctrl+Shift+D: 切换深色/浅色主题
if (ctrl && shift && e.key.toLowerCase() === 'd') {
e.preventDefault()
appStore.isDark = !appStore.isDark
return
}
// Alt+H: 返回首页
if (alt && e.key.toLowerCase() === 'h') {
e.preventDefault()
router.push('/')
return
}
}
onMounted(() => {
window.addEventListener('keydown', handleGlobalKeydown)
window.$importModal = globalImportModalRef.value
window.$exportModal = globalExportModalRef.value
window.$attachmentModal = globalAttachmentModalRef.value
window.$uploadModal = globalUploadModalRef.value
window.$previewModal = globalPreviewModalRef.value
window.$downloadModal = globalDownloadModalRef.value
})
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown)
})
</script>
<style scoped>
/* 无动画 */
.none-enter-active,
.none-leave-active {
transition: none !important;
}
/* 淡入淡出 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 滑动渐变 (原默认动画) */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(20px);
}
/* 缩放渐变 */
.zoom-enter-active,
.zoom-leave-active {
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.zoom-enter-from,
.zoom-leave-to {
opacity: 0;
transform: scale(0.96);
}
/* 沉浸式风格:选中背景充满侧边栏宽度 */
:deep(.n-menu-item-content) {
border-radius: 0 !important;
margin: 1px 0 !important; /* 保持极小的上下间隙以区分项 */
transition: all 0.3s ease !important;
}
/* 修复折叠状态下的全宽居中问题 */
:deep(.n-menu.n-menu--collapsed .n-menu-item-content) {
padding: 0 !important;
margin: 1px 0 !important;
display: flex !important;
justify-content: center !important;
align-items: center !important;
width: 64px !important; /* 必须显式指定宽度以覆盖 Naive UI 内部计算值 */
}
:deep(.n-menu.n-menu--collapsed .n-menu-item-content .n-menu-item-content__icon) {
margin-right: 0 !important;
}
/* 确保折叠时文字和箭头不占位 */
:deep(.n-menu.n-menu--collapsed .n-menu-item-content-header),
:deep(.n-menu.n-menu--collapsed .n-menu-item-content__arrow) {
display: none !important;
}
:deep(.n-menu-item-content--selected) {
font-weight: 600;
}
/* 自定义折叠按钮区域 */
.sider-collapse-btn-wrap {
padding: 10px 0 12px;
display: flex;
justify-content: center;
border-top: 1px solid;
}
.sider-collapse-btn {
width: 40px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid;
border-radius: 6px;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
}
.sider-collapse-btn:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
transform: translateY(-1px);
}
/* sider 内部 flex 列布局,按钮固定底部 */
.sider-with-trigger :deep(.n-layout-sider-scroll-container) {
overflow: hidden !important;
}
.sider-inner {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.sider-menu-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
</style>
export default {
common: {
confirm: 'Confirm',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
search: 'Search',
reset: 'Reset',
logout: 'Logout'
}
}
import { createI18n } from 'vue-i18n'
import zhCN from './zh-CN/index'
import enUS from './en-US/index'
const i18n = createI18n({
legacy: false, // Use Composition API
locale: localStorage.getItem('lang') || 'zh-CN',
fallbackLocale: 'en-US',
messages: {
'zh-CN': zhCN,
'en-US': enUS
}
})
export default i18n
export default {
common: {
confirm: '确定',
cancel: '取消',
save: '保存',
delete: '删除',
edit: '编辑',
add: '新增',
search: '查询',
reset: '重置',
logout: '退出登录'
}
}
import './style.css'
import App from './App.vue'
import router from './router'
import pinia from './store'
import i18n from './locales'
import 'vfonts/Lato.css'
import 'vfonts/FiraCode.css'
import { setupNaiveDefaults } from '@/plugins/naive-ui-defaults'
// 设置 Naive UI 组件默认属性
setupNaiveDefaults()
import { setupNaiveDiscreteApi } from '@/utils/naive'
const app = createApp(App)
app.use(pinia)
app.use(i18n)
app.use(router)
// 初始化全局 Discrete API (window.$message, window.$dialog 等)
// 必须在 app.use(pinia) 之后,确保 hooks 内部的 store 可用
setupNaiveDiscreteApi()
app.mount('#app')
import { NInput, NDatePicker, NTimePicker, NCascader, NInputNumber, NTreeSelect, NAutoComplete, NMention, NColorPicker, NRate } from 'naive-ui'
/**
* 默认开启所有表单组件的 clearable 属性
*/
const components = [NInput, NDatePicker, NTimePicker, NCascader, NInputNumber, NTreeSelect, NAutoComplete, NMention, NColorPicker, NRate]
export function setupNaiveDefaults() {
components.forEach((component: any) => {
if (component.props && component.props.clearable !== undefined) {
const clearable = component.props.clearable
if (typeof clearable === 'object' && clearable !== null && !Array.isArray(clearable)) {
clearable.default = true
} else {
// 如果是 Boolean 构造函数或数组,则重写为对象形式
component.props.clearable = {
type: clearable,
default: true
}
}
}
})
}
import type { RouteRecordRaw } from 'vue-router'
import MainLayout from '@/layouts/MainLayout.vue'
// 静态路由
const constantRoutes: Array<RouteRecordRaw> = [
{
path: '/404',
name: '404',
component: () => import('@/views/404.vue'),
meta: { title: '404' }
},
{
path: '/',
name: 'layout',
component: MainLayout,
children: [
{
path: '/theme',
name: 'theme',
component: () => import('@/views/theme/index.vue'),
meta: { title: '主题色板' }
},
{
path: '/xml-editor',
name: 'xml-editor',
component: () => import('@/views/xmlEditor.vue'),
meta: { title: '工卡 XML 编辑器' }
}
]
},
{
path: '/views/:pathMatch(.*)*',
component: MainLayout
}
]
const router = createRouter({
history: createWebHashHistory(),
routes: constantRoutes
})
router.beforeEach(() => {
window.$loadingBar?.start()
})
router.afterEach((to) => {
window.$loadingBar?.finish()
const title = (to.meta?.title as string) || (to.name as string)
if (title && title !== 'layout') {
document.title = `${title} | AMRO 系统`
} else {
document.title = 'AMRO 系统'
}
})
router.onError(() => {
window.$loadingBar?.error()
})
export default router
import type { AppState } from './types'
export const useAppStore = defineStore('app', {
state: (): AppState => ({
collapsed: false,
isDark: false,
primaryColor: '#165DFF',
borderRadius: 0,
fontSize: 14,
colorWeak: false,
grayMode: false,
showBreadcrumb: true,
contentCompact: true,
fixedHeader: false,
rememberMe: false,
savedUsername: '',
savedPassword: '',
loading: false,
loadingText: '加载中...',
transitionName: 'none',
settingsPinned: false,
settingsOpen: false
}),
actions: {
toggleSidebar() {
this.collapsed = !this.collapsed
},
applyTheme() {
const root = document.documentElement
root.style.setProperty('--primary-color', this.primaryColor)
root.style.setProperty('--border-radius-base', `${this.borderRadius}rem`)
root.style.setProperty('--font-size-base', `${this.fontSize}px`)
document.body.style.fontSize = `${this.fontSize}px`
document.body.classList.toggle('color-weak', this.colorWeak)
document.body.classList.toggle('gray-mode', this.grayMode)
}
},
persist: true
})
export interface AppState {
collapsed: boolean
isDark: boolean
primaryColor: string
borderRadius: number
fontSize: number
colorWeak: boolean
grayMode: boolean
// 布局
showBreadcrumb: boolean
contentCompact: boolean
fixedHeader: boolean
rememberMe: boolean
savedUsername: string
savedPassword: string
// 全局加载状态
loading: boolean
loadingText?: string
transitionName: string
settingsPinned: boolean
settingsOpen: boolean
}
import { findNodeById, serializeTreeToXml, findParentNode, parseXmlToTree } from '@/utils/xmlParser'
import type { XmlNode } from '@/types/xmlNode'
import type { EditorState } from './types'
export const useEditorStore = defineStore('editor', {
state: (): EditorState => ({
xmlTree: null,
selectedNodeId: null,
undoStack: [],
redoStack: []
}),
getters: {
selectedNode(state): XmlNode | null {
if (!state.xmlTree || !state.selectedNodeId) return null
return findNodeById(state.xmlTree, state.selectedNodeId)
},
selectedNodeParent(state): XmlNode | null {
if (!state.xmlTree || !state.selectedNodeId) return null
return findParentNode(state.xmlTree, state.selectedNodeId)
}
},
actions: {
setXmlTree(tree: XmlNode) {
this.xmlTree = tree
this.selectedNodeId = tree.id
this.undoStack = []
this.redoStack = []
},
setSelectedNodeId(id: string | null) {
this.selectedNodeId = id
},
/**
* 强制触发当前状态同步快照
*/
triggerSync() {
this.saveSnapshot()
},
/**
* 保存当前状态到撤销栈
*/
saveSnapshot() {
if (!this.xmlTree) return
const xmlStr = serializeTreeToXml(this.xmlTree)
// 限制撤销栈大小为 50
if (this.undoStack.length >= 50) {
this.undoStack.shift()
}
this.undoStack.push(xmlStr)
// 每次新操作后,清空重做栈
this.redoStack = []
},
/**
* 撤销
*/
undo() {
if (this.undoStack.length === 0 || !this.xmlTree) return
const currentXml = serializeTreeToXml(this.xmlTree)
this.redoStack.push(currentXml)
const previousXml = this.undoStack.pop()!
// 重新解析上一个 XML 状态并设置
const tree = parseXmlToTree(previousXml)
// 保持选中 ID 存在于新树中,若不存在则默认选中根节点
this.xmlTree = tree
if (this.selectedNodeId && !findNodeById(tree, this.selectedNodeId)) {
this.selectedNodeId = tree.id
}
},
/**
* 重做
*/
redo() {
if (this.redoStack.length === 0 || !this.xmlTree) return
const currentXml = serializeTreeToXml(this.xmlTree)
this.undoStack.push(currentXml)
const nextXml = this.redoStack.pop()!
const tree = parseXmlToTree(nextXml)
this.xmlTree = tree
if (this.selectedNodeId && !findNodeById(tree, this.selectedNodeId)) {
this.selectedNodeId = tree.id
}
},
/**
* 更新当前选中节点的属性
*/
updateSelectedNodeAttributes(attributes: Record<string, string>) {
const node = this.selectedNode
if (!node) return
this.saveSnapshot()
node.attributes = { ...attributes }
},
/**
* 更新当前选中节点的文本内容
*/
updateSelectedNodeText(text: string) {
const node = this.selectedNode
if (!node) return
this.saveSnapshot()
node.textContent = text
node.mixedContent = [] // 若纯文本修改,清空混合内容
},
/**
* 更新当前选中节点的混合内容
*/
updateSelectedNodeMixedContent(mixedContent: any[], children: XmlNode[]) {
const node = this.selectedNode
if (!node) return
this.saveSnapshot()
node.mixedContent = mixedContent
node.children = children
node.textContent = '' // 清空纯文本内容
},
/**
* 向当前选中节点添加子节点
*/
addChildNode(newNode: XmlNode, index?: number) {
const node = this.selectedNode
if (!node) return
this.saveSnapshot()
newNode.parentId = node.id
if (index !== undefined) {
node.children.splice(index, 0, newNode)
} else {
node.children.push(newNode)
}
},
/**
* 删除当前选中的节点
*/
deleteSelectedNode() {
if (!this.xmlTree || !this.selectedNodeId || this.selectedNodeId === this.xmlTree.id) {
return // 不能删除根节点
}
const parent = this.selectedNodeParent
if (!parent) return
this.saveSnapshot()
const index = parent.children.findIndex((c: XmlNode) => c.id === this.selectedNodeId)
if (index !== -1) {
parent.children.splice(index, 1)
// 处理混合内容 (mixedContent) 中的对应项
parent.mixedContent = parent.mixedContent.filter((item: any) => item.nodeId !== this.selectedNodeId)
// 选中父节点
this.selectedNodeId = parent.id
}
},
/**
* 移动子节点位置
*/
moveChildNode(nodeId: string, direction: 'up' | 'down') {
const parent = findParentNode(this.xmlTree!, nodeId)
if (!parent) return
const index = parent.children.findIndex((c) => c.id === nodeId)
if (index === -1) return
const targetIndex = direction === 'up' ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= parent.children.length) return
this.saveSnapshot()
// 交换 children 中的元素
const temp = parent.children[index]
parent.children[index] = parent.children[targetIndex]
parent.children[targetIndex] = temp
// 同步调整 mixedContent 里的顺序
const mixedIndex1 = parent.mixedContent.findIndex((item) => item.nodeId === nodeId)
const otherNodeId = parent.children[index].id
const mixedIndex2 = parent.mixedContent.findIndex((item) => item.nodeId === otherNodeId)
if (mixedIndex1 !== -1 && mixedIndex2 !== -1) {
const tempMixed = parent.mixedContent[mixedIndex1]
parent.mixedContent[mixedIndex1] = parent.mixedContent[mixedIndex2]
parent.mixedContent[mixedIndex2] = tempMixed
}
}
}
})
import type { XmlNode } from '@/types/xmlNode'
export interface EditorState {
xmlTree: XmlNode | null
selectedNodeId: string | null
undoStack: string[] // 保存 XML 字符串的历史快照
redoStack: string[] // 保存 XML 字符串的重做快照
}
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export default pinia
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom Scrollbar Styling - Theme Aware */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.2);
border-radius: 10px;
transition: background 0.3s;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(128, 128, 128, 0.4);
}
/* For Firefox */
* {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.3) transparent;
}
/* Naive UI internal scrollbar overrides for better theme integration */
.n-scrollbar-rail,
.n-data-table .n-scrollbar-rail {
background-color: transparent !important;
opacity: 1 !important;
}
.n-scrollbar-rail--vertical {
width: 6px !important;
}
.n-scrollbar-rail--horizontal {
height: 6px !important;
}
.n-scrollbar-rail > .n-scrollbar-rail__scrollbar {
background-color: rgba(128, 128, 128, 0.2) !important;
}
.n-scrollbar-rail:hover > .n-scrollbar-rail__scrollbar {
background-color: rgba(128, 128, 128, 0.4) !important;
}
.n-button:not(:last-child) {
margin-right: 10px;
}
.n-data-table .n-data-table__pagination{
margin: 0;
}
/* 全局弹框组件样式 */
.app-modal {
border-radius: 12px;
}
\ No newline at end of file
/**
* 全局通用类型定义 (Global Types)
* 此文件定义的类型可以在全项目直接使用,无需 import
*/
import type { DialogOptions, MessageOptions, NotificationOptions } from 'naive-ui'
declare global {
/**
* 基础响应结构
*/
interface ResponseData<T = any> {
code: number | string
msg: string
data: T
total?: number
[key: string]: any
}
/**
* 业务封装后的 Dialog API (Promise 风格)
*/
interface CustomDialogApi {
info: (titleOrOptions: string | DialogOptions, content?: string, config?: DialogOptions) => Promise<boolean>
error: (titleOrOptions: string | DialogOptions, content?: string, config?: DialogOptions) => Promise<boolean>
success: (titleOrOptions: string | DialogOptions, content?: string, config?: DialogOptions) => Promise<boolean>
warning: (titleOrOptions: string | DialogOptions, content?: string, config?: DialogOptions) => Promise<boolean>
}
/**
* 业务封装后的 Message API
*/
interface CustomMessageApi {
info: (content: string, config?: MessageOptions) => void
error: (content: string, config?: MessageOptions) => void
success: (content: string, config?: MessageOptions) => void
warning: (content: string, config?: MessageOptions) => void
}
/**
* 业务封装后的 Notification API
*/
interface CustomNotificationApi {
info: (content: string, config?: NotificationOptions) => void
error: (content: string, config?: NotificationOptions) => void
success: (content: string, config?: NotificationOptions) => void
warning: (content: string, config?: NotificationOptions) => void
}
/**
* 增加 window 全局变量定义 (用于 Discrete API)
*/
interface Window {
$message: CustomMessageApi
$dialog: CustomDialogApi
$notification: CustomNotificationApi
$loadingBar: import('naive-ui').LoadingBarApi
$loading: {
start: (text?: string) => void
finish: () => void
}
$importModal: any
$exportModal: any
$attachmentModal: any
$uploadModal: any
$previewModal: any
$downloadModal: any
$modal: {
open: (options: any) => import('naive-ui').ModalReactive
}
}
}
export {}
/**
* 扩展 Naive UI 主题类型,使 useThemeVars() 能够识别项目自定义的色阶 Token
* 扩展目标:CustomThemeCommonVars(naive-ui 专为用户扩展预留的空接口)
* useThemeVars() 返回类型为 ThemeCommonVars & CustomThemeCommonVars,因此扩展后可直接访问
* 对应 src/configs/tailwind.ui.config.ts 中定义的扩展变量
*/
declare module 'naive-ui' {
interface CustomThemeCommonVars {
// 主色阶
primary1?: string
primary2?: string
primary3?: string
primary4?: string
primary5?: string
primary6?: string
primary7?: string
// 成功色阶
success1?: string
success2?: string
success3?: string
success4?: string
success5?: string
success6?: string
success7?: string
// 警示色阶
warning1?: string
warning2?: string
warning3?: string
warning4?: string
warning5?: string
warning6?: string
warning7?: string
// 错误/危险色阶
danger1?: string
danger2?: string
danger3?: string
danger4?: string
danger5?: string
danger6?: string
danger7?: string
// 链接色阶
link1?: string
link2?: string
link3?: string
link4?: string
link5?: string
link6?: string
link7?: string
// 边框颜色
colorBorder1?: string
colorBorder2?: string
colorBorder3?: string
colorBorder4?: string
// 填充颜色
colorFill1?: string
colorFill2?: string
colorFill3?: string
colorFill4?: string
// 文字颜色
colorText1?: string
colorText2?: string
colorText3?: string
colorText4?: string
// 背景颜色
colorBg1?: string
colorBg2?: string
colorBg3?: string
colorBg4?: string
colorBg5?: string
}
}
/**
* XML 节点统一数据结构
* 用于在编辑器中表示 XML 文档树的每一个元素节点
*/
export interface XmlNode {
/** 唯一标识 */
id: string
/** 元素标签名,如 "CEP", "PARA" */
tagName: string
/** 元素属性键值对 */
attributes: Record<string, string>
/** 子节点列表 */
children: XmlNode[]
/** 文本内容(#PCDATA),混合内容节点中文本片段 */
textContent: string
/** 混合内容片段列表(文本和子元素交替出现时使用) */
mixedContent: MixedContentItem[]
/** 父节点 ID(不参与序列化) */
parentId: string | null
}
/**
* 混合内容项
* 用于表示 PARA 等混合内容节点中文本和行内元素交替出现的情况
*/
export interface MixedContentItem {
/** 内容类型 */
type: 'text' | 'element'
/** 文本内容(type=text 时) */
text?: string
/** 子元素节点 ID(type=element 时) */
nodeId?: string
}
/**
* DTD 中元素的内容模型(解析后的结构)
*/
export interface DtdContentModel {
type: 'sequence' | 'choice' | 'elementRef' | 'pcdata' | 'mixed' | 'empty'
occurrence: 'once' | 'optional' | 'zeroOrMore' | 'oneOrMore'
name?: string
children?: DtdContentModel[]
}
/**
* DTD 中元素的属性定义
*/
export interface DtdAttribute {
typeDefinition: string
parsedType: string
requirement: string
enumValues: string[] | null
defaultValue: string | null
}
/**
* DTD 中元素的完整定义
*/
export interface DtdElement {
description: string
contentModel: {
raw: string
parsed: DtdContentModel
humanReadable: string
}
attributes: Record<string, DtdAttribute>
allowedChildren: string[]
}
/**
* DTD JSON 的根结构
*/
export interface DtdSchema {
elements: Record<string, DtdElement>
}
/**
* 编辑器操作历史记录
*/
export interface EditorAction {
type: 'update_attribute' | 'update_text' | 'add_node' | 'delete_node' | 'move_node'
timestamp: number
/** 操作前的快照数据 */
before: any
/** 操作后的快照数据 */
after: any
/** 受影响的节点 ID */
nodeId: string
}
/**
* 节点树显示配置
*/
export interface TreeNodeOption {
key: string
label: string
tagName: string
isLeaf: boolean
children?: TreeNodeOption[]
prefix?: string
}
import { service } from '@/api/index'
/**
* 通用列表数据请求工具
* 逻辑来源:抽离自 CommonTable 和 CommonSelect 的数据获取逻辑
* @param url 接口地址
* @param params 请求参数
* @returns 统一格式的列表数据对象
*/
export async function requestListData(url: string, params: any = {}, method: 'POST' | 'GET' = 'POST') {
try {
let res: any
if (method === 'GET') {
const queryParams = new URLSearchParams()
Object.keys(params).forEach((key) => {
const val = params[key]
if (val !== null && val !== undefined) {
queryParams.append(key, val)
}
})
res = await service.get(`${url}?${queryParams.toString()}`)
} else {
res = await service.post(url, params)
}
if (res.code === 200) {
let data = res.data
let rows: any[] = []
// 0. 支持 EasyUI 根路径下直接返回 rows 数组的结构
if (Array.isArray(res.rows)) {
rows = res.rows
}
// 1. 基础数组直接返回
else if (Array.isArray(data)) {
rows = data
}
// 2. 对象结构处理
else if (data && typeof data === 'object') {
// 优先匹配 rows (标准表格分页)
if (Array.isArray(data.rows)) {
rows = data.rows
}
// 匹配和 domainCode 相同的 key (字典/域查询常见)
else if (params.domainCode && Array.isArray(data[params.domainCode])) {
rows = data[params.domainCode]
}
// 查找首个数组字段或特定的 "0" 字段
else {
const arrayKey = Object.keys(data).find((k) => k !== 'code' && Array.isArray(data[k]))
if (arrayKey) {
rows = data[arrayKey]
} else if (Array.isArray(data['0'])) {
rows = data['0']
}
}
}
// 计算总数:优先取外层 total,次之取 data.total,最后取数组长度
const total = res.total !== undefined ? res.total : data?.total !== undefined ? data.total : rows.length
return {
success: true,
data: rows,
total,
raw: res // 保留原始响应以备不时之需
}
}
return { success: false, data: [], total: 0, raw: res }
} catch (e) {
console.error(`Request list data failed [${url}]:`, e)
return { success: false, data: [], total: 0, error: e }
}
}
import dayjs from 'dayjs'
/**
* 格式化日期时间
* @param date 日期
* @param format 格式,默认 YYYY-MM-DD HH:mm:ss
*/
export function formatDateTime(date: string | number | Date | any, format = 'YYYY-MM-DD HH:mm:ss') {
if (!date || date === '-' || date === 'null' || date === 'undefined') return '-'
// 处理后端返回的奇怪对象格式 (有些后端返回的是带 year, monthValue 等属性的对象)
if (typeof date === 'object' && date.year && date.monthValue) {
return dayjs()
.set('year', date.year)
.set('month', date.monthValue - 1)
.set('date', date.dayOfMonth)
.set('hour', date.hour || 0)
.set('minute', date.minute || 0)
.set('second', date.second || 0)
.format(format)
}
const d = dayjs(date)
return d.isValid() ? d.format(format) : '-'
}
/**
* 格式化日期
* @param date 日期
* @param format 格式,默认 YYYY-MM-DD
*/
export function formatDate(date: string | number | Date | any, format = 'YYYY-MM-DD') {
if (!date || date === '-' || date === 'null' || date === 'undefined') return null
const d = dayjs(date)
return d.isValid() ? d.format(format) : null
}
/**
* 获取当前日期时间
*/
export function now(format = 'YYYY-MM-DD HH:mm:ss') {
return dayjs().format(format)
}
/**
* 计算相对时间 (如:几分钟前)
* @param date 日期
*/
export function fromNow(date: string | number | Date | any) {
if (!date) return '-'
// 如果需要更复杂的相对时间,通常需要引入 dayjs/plugin/relativeTime
return dayjs(date).toString() // 占位,之后可按需增强
}
/**
* 毫秒转可读时间字符串
* @param ms 毫秒数
* @returns 格式化的时间字符串(如:1天2小时3分4秒 或 26毫秒)
*/
export function toMsTimeString(ms: number | string | null | undefined) {
if (ms === null || ms === undefined || ms === '') return ''
const numMs = Number(ms)
// 如果小于1s,显示毫秒 (满足高精度需求)
if (numMs < 1000 && numMs > 0) return `${numMs}毫秒`
if (numMs <= 0) return '0秒'
let totalSeconds = Math.floor(numMs / 1000)
const d = Math.floor(totalSeconds / (24 * 3600))
totalSeconds %= 24 * 3600
const h = Math.floor(totalSeconds / 3600)
totalSeconds %= 3600
const m = Math.floor(totalSeconds / 60)
const s = totalSeconds % 60
let res = ''
if (d > 0) res += `${d}天`
if (h > 0) res += `${h}小时`
if (m > 0) res += `${m}分`
if (s > 0 || res === '') res += `${s}秒`
return res
}
/**
* 增加天数并格式化
* @param dateStr 日期字符串
* @param days 增加的天数
* @param format 格式,默认 YYYY-MM-DD
*/
export function addDate(dateStr: string | number | Date | any, days: number, format = 'YYYY-MM-DD') {
if (!dateStr || dateStr === '-' || dateStr === 'null' || dateStr === 'undefined') return ''
const d = dayjs(dateStr)
return d.isValid() ? d.add(days, 'day').format(format) : ''
}
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment