Commit eba41c1ba40e832ce36268f30dede7f88ea47ccd

Authored by “wangming”
1 parent c4040263

fix: 修复小程序登录闪烁、耗卡跨月策略与开单顾客类型兜底

- 登录页设为启动页并统一 redirectToLogin,避免多接口 600 连续 reLaunch
- 小程序耗卡可编辑天数/跨月策略与后台配置对齐
- 开单 gjlx 自动补全与台账顾客类型展示兜底
- 系统配置页拆分面板、短信日志抽屉样式优化

Co-authored-by: Cursor <cursoragent@cursor.com>
Showing 30 changed files with 2377 additions and 989 deletions
antis-ncc-admin/src/utils/consume-admin-policy.js
@@ -41,6 +41,7 @@ export function normalizeBusinessConfigFields(form) { @@ -41,6 +41,7 @@ export function normalizeBusinessConfigFields(form) {
41 } 41 }
42 42
43 intField('consumeEditableDays', 3) 43 intField('consumeEditableDays', 3)
  44 + intField('consumeAllowCrossMonthEdit', 0)
44 intField('adminConsumeVoidEditableDays', 0) 45 intField('adminConsumeVoidEditableDays', 0)
45 intField('adminConsumeDeleteEditableDays', 0) 46 intField('adminConsumeDeleteEditableDays', 0)
46 intField('sleepRemindDays', 30) 47 intField('sleepRemindDays', 30)
antis-ncc-admin/src/views/system/sysConfig/ai-config-panel.vue 0 → 100644
  1 +<template>
  2 + <ConfigPanelShell
  3 + :modules="modules"
  4 + :loading="loading"
  5 + :saving="saving"
  6 + storage-key="aiConfigActiveModule"
  7 + alert-title="通义千问(DashScope)语音识别与文本总结配置;语音与对话可使用不同密钥。"
  8 + alert-type="info"
  9 + @save="$emit('save')"
  10 + >
  11 + <template #speech>
  12 + <el-card shadow="never" class="config-section">
  13 + <div slot="header" class="config-section__header">
  14 + <span>语音识别</span>
  15 + <span class="config-section__tag">Paraformer 异步转写</span>
  16 + </div>
  17 + <p class="config-section__tip">
  18 + 录音文件识别专用;留空地址则使用官方默认接口。
  19 + <strong>禁止</strong>填写对话用的 <code>compatible-mode/v1</code>。
  20 + </p>
  21 + <p class="config-section__tip config-section__tip--code">
  22 + 默认地址:<code>{{ defaultParaformerSubmitUrl }}</code>
  23 + </p>
  24 + <el-form-item label="语音服务地址" prop="aiSpeechApiUrl">
  25 + <el-input v-model="form.aiSpeechApiUrl" clearable
  26 + placeholder="留空即可;请勿填 compatible-mode/v1(那是对话网关)" />
  27 + </el-form-item>
  28 + <el-form-item label="语音 API Key" prop="aiSpeechApiKey">
  29 + <el-input v-model="form.aiSpeechApiKey" show-password clearable autocomplete="new-password"
  30 + placeholder="语音识别使用的 API Key" />
  31 + </el-form-item>
  32 + <el-form-item label="语音识别模型" prop="aiSpeechModel">
  33 + <el-input v-model="form.aiSpeechModel" clearable
  34 + placeholder="paraformer-v2 或 paraformer-8k-v2" />
  35 + </el-form-item>
  36 + <p class="config-section__tip config-section__tip--inline">
  37 + 与百炼「录音文件识别」的 model 一致;留空默认 <code>paraformer-v2</code>。禁止填 qwen / livetranslate / realtime 类模型。
  38 + </p>
  39 + </el-card>
  40 + </template>
  41 +
  42 + <template #chat>
  43 + <el-card shadow="never" class="config-section">
  44 + <div slot="header" class="config-section__header">
  45 + <span>对话与文本总结</span>
  46 + <span class="config-section__tag">OpenAI 兼容模式</span>
  47 + </div>
  48 + <p class="config-section__tip">用于文案总结等场景,需与模型名称搭配(如兼容模式接入千问)。</p>
  49 + <el-form-item label="对话接口地址" prop="aiChatApiUrl">
  50 + <el-input v-model="form.aiChatApiUrl" clearable
  51 + placeholder="示例:https://dashscope.aliyuncs.com/compatible-mode/v1" />
  52 + </el-form-item>
  53 + <el-form-item label="模型名称" prop="aiChatModel">
  54 + <el-input v-model="form.aiChatModel" clearable placeholder="如 qwen-plus、qwen-turbo" />
  55 + </el-form-item>
  56 + <el-form-item label="文本 API Key" prop="aiChatApiKey">
  57 + <el-input v-model="form.aiChatApiKey" show-password clearable autocomplete="new-password"
  58 + placeholder="可与语音使用同一密钥或单独的子 Key" />
  59 + </el-form-item>
  60 + </el-card>
  61 + </template>
  62 +
  63 + <template #prompt>
  64 + <el-card shadow="never" class="config-section">
  65 + <div slot="header" class="config-section__header">
  66 + <span>备注归纳提示词</span>
  67 + <span class="config-section__tag">system 角色</span>
  68 + </div>
  69 + <p class="config-section__tip">
  70 + 发给千问的 system 角色,用来约束「删掉废话、保留关键」。留空则使用系统内置默认(美业健康师备注风格)。
  71 + </p>
  72 + <el-form-item label="归纳提示词" prop="aiSummarizeSystemPrompt">
  73 + <el-input v-model="form.aiSummarizeSystemPrompt" type="textarea"
  74 + :autosize="{ minRows: 8, maxRows: 20 }"
  75 + placeholder="例如:你是一名资深美业咨询师,请将下方口语转写整理为一句专业、可追溯的工单备注……" />
  76 + </el-form-item>
  77 + </el-card>
  78 + </template>
  79 + </ConfigPanelShell>
  80 +</template>
  81 +
  82 +<script>
  83 +import ConfigPanelShell from './config-panel-shell.vue'
  84 +
  85 +const MODULES = [
  86 + { key: 'speech', label: '语音识别', desc: 'Paraformer 录音转写', icon: 'el-icon-microphone', iconClass: 'module-icon--speech' },
  87 + { key: 'chat', label: '对话总结', desc: '千问兼容模式接口', icon: 'el-icon-chat-dot-round', iconClass: 'module-icon--chat' },
  88 + { key: 'prompt', label: '归纳提示词', desc: '备注整理 system 提示', icon: 'el-icon-edit-outline', iconClass: 'module-icon--prompt' }
  89 +]
  90 +
  91 +export default {
  92 + name: 'AiConfigPanel',
  93 + components: { ConfigPanelShell },
  94 + props: {
  95 + form: { type: Object, required: true },
  96 + loading: { type: Boolean, default: false },
  97 + saving: { type: Boolean, default: false },
  98 + defaultParaformerSubmitUrl: { type: String, default: '' }
  99 + },
  100 + data() {
  101 + return { modules: MODULES }
  102 + }
  103 +}
  104 +</script>
  105 +
  106 +<style lang="scss" scoped>
  107 +.config-section {
  108 + border: 1px solid #ebeef5;
  109 +
  110 + &__header {
  111 + display: flex;
  112 + align-items: center;
  113 + gap: 8px;
  114 + font-weight: 500;
  115 + }
  116 +
  117 + &__tag {
  118 + padding: 0 8px;
  119 + height: 22px;
  120 + line-height: 22px;
  121 + border-radius: 4px;
  122 + background: #f4f4f5;
  123 + color: #909399;
  124 + font-size: 12px;
  125 + font-weight: normal;
  126 + }
  127 +
  128 + &__tip {
  129 + margin: 0 0 12px;
  130 + color: #909399;
  131 + font-size: 13px;
  132 + line-height: 1.6;
  133 +
  134 + &--inline {
  135 + margin-top: 0;
  136 + }
  137 +
  138 + &--code {
  139 + margin-top: -4px;
  140 + }
  141 +
  142 + code {
  143 + padding: 1px 4px;
  144 + border-radius: 3px;
  145 + background: #f4f4f5;
  146 + font-size: 12px;
  147 + }
  148 + }
  149 +}
  150 +
  151 +.module-icon--speech { color: #409EFF; }
  152 +.module-icon--chat { color: #67C23A; }
  153 +.module-icon--prompt { color: #E6A23C; }
  154 +
  155 +.ncc-content-dark .config-section {
  156 + border-color: rgba(255, 255, 255, 0.08);
  157 +
  158 + &__tag {
  159 + background: rgba(255, 255, 255, 0.08);
  160 + color: rgba(255, 255, 255, 0.55);
  161 + }
  162 +
  163 + &__tip {
  164 + color: rgba(255, 255, 255, 0.55);
  165 +
  166 + code {
  167 + background: rgba(255, 255, 255, 0.08);
  168 + }
  169 + }
  170 +}
  171 +</style>
antis-ncc-admin/src/views/system/sysConfig/basic-config-panel.vue 0 → 100644
  1 +<template>
  2 + <ConfigPanelShell
  3 + :modules="modules"
  4 + :loading="loading"
  5 + :saving="saving"
  6 + storage-key="basicConfigActiveModule"
  7 + alert-title="配置系统品牌标识与公司基础信息;保存后全站生效。"
  8 + @save="$emit('save')"
  9 + >
  10 + <template #brand>
  11 + <el-card shadow="never" class="config-section">
  12 + <div slot="header" class="config-section__header">
  13 + <span>品牌标识</span>
  14 + <span class="config-section__tag">侧栏 / 浏览器标题</span>
  15 + </div>
  16 + <p class="config-section__tip">上传系统 Logo 与折叠图标,用于管理后台侧栏与页面标题展示。</p>
  17 + <el-row :gutter="24" class="brand-row">
  18 + <el-col :xs="24" :sm="12">
  19 + <el-form-item label="系统 Logo">
  20 + <div class="img-uploader" v-loading="logoUploading">
  21 + <el-upload class="single-uploader" :show-file-list="false" action="#" accept="image/*"
  22 + :http-request="onUploadLogo">
  23 + <img v-if="form.sysLogo" :src="logoPreview(form.sysLogo)" class="single-img">
  24 + <i v-else class="el-icon-plus single-icon"></i>
  25 + </el-upload>
  26 + <el-button v-if="form.sysLogo" type="text" class="NCC-table-delBtn" @click="form.sysLogo = ''">移除</el-button>
  27 + </div>
  28 + <p class="form-tip">侧栏展开时显示,建议 32×32 或等比透明图</p>
  29 + </el-form-item>
  30 + </el-col>
  31 + <el-col :xs="24" :sm="12">
  32 + <el-form-item label="折叠图标">
  33 + <div class="img-uploader" v-loading="logoIconUploading">
  34 + <el-upload class="single-uploader" :show-file-list="false" action="#" accept="image/*"
  35 + :http-request="onUploadLogoIcon">
  36 + <img v-if="form.sysLogoIcon" :src="logoPreview(form.sysLogoIcon)" class="single-img">
  37 + <i v-else class="el-icon-plus single-icon"></i>
  38 + </el-upload>
  39 + <el-button v-if="form.sysLogoIcon" type="text" class="NCC-table-delBtn"
  40 + @click="form.sysLogoIcon = ''">移除</el-button>
  41 + </div>
  42 + <p class="form-tip">可选;侧栏折叠时显示,不传则使用系统 Logo</p>
  43 + </el-form-item>
  44 + </el-col>
  45 + </el-row>
  46 + <el-row :gutter="20">
  47 + <el-col :span="12">
  48 + <el-form-item label="系统名称" prop="sysName">
  49 + <el-input v-model="form.sysName" clearable placeholder="系统名称" />
  50 + </el-form-item>
  51 + </el-col>
  52 + <el-col :span="12">
  53 + <el-form-item label="系统版本" prop="sysVersion">
  54 + <el-input v-model="form.sysVersion" clearable placeholder="系统版本" />
  55 + </el-form-item>
  56 + </el-col>
  57 + </el-row>
  58 + </el-card>
  59 + </template>
  60 +
  61 + <template #company>
  62 + <el-card shadow="never" class="config-section">
  63 + <div slot="header" class="config-section__header">
  64 + <span>公司信息</span>
  65 + <span class="config-section__tag">版权与联系</span>
  66 + </div>
  67 + <p class="config-section__tip">用于登录页、页脚版权及系统描述展示。</p>
  68 + <el-row :gutter="20">
  69 + <el-col :span="12">
  70 + <el-form-item label="公司名称" prop="companyName">
  71 + <el-input v-model="form.companyName" clearable placeholder="公司名称" />
  72 + </el-form-item>
  73 + </el-col>
  74 + <el-col :span="12">
  75 + <el-form-item label="公司简称" prop="companyCode">
  76 + <el-input v-model="form.companyCode" clearable placeholder="公司简称" />
  77 + </el-form-item>
  78 + </el-col>
  79 + <el-col :span="12">
  80 + <el-form-item label="版权信息" prop="copyright">
  81 + <el-input v-model="form.copyright" clearable placeholder="版权信息" />
  82 + </el-form-item>
  83 + </el-col>
  84 + <el-col :span="12">
  85 + <el-form-item label="公司法人" prop="companyContacts">
  86 + <el-input v-model="form.companyContacts" clearable placeholder="公司法人" />
  87 + </el-form-item>
  88 + </el-col>
  89 + <el-col :span="12">
  90 + <el-form-item label="公司电话" prop="companyTelePhone">
  91 + <el-input v-model="form.companyTelePhone" clearable placeholder="公司电话" />
  92 + </el-form-item>
  93 + </el-col>
  94 + <el-col :span="12">
  95 + <el-form-item label="公司邮箱" prop="companyEmail">
  96 + <el-input v-model="form.companyEmail" clearable placeholder="公司邮箱" />
  97 + </el-form-item>
  98 + </el-col>
  99 + <el-col :span="24">
  100 + <el-form-item label="公司地址" prop="companyAddress">
  101 + <el-input v-model="form.companyAddress" clearable placeholder="公司地址" />
  102 + </el-form-item>
  103 + </el-col>
  104 + <el-col :span="24">
  105 + <el-form-item label="系统描述" prop="sysDescription">
  106 + <el-input v-model="form.sysDescription" type="textarea" :autosize="{ minRows: 4, maxRows: 10 }"
  107 + placeholder="系统描述" />
  108 + </el-form-item>
  109 + </el-col>
  110 + </el-row>
  111 + </el-card>
  112 + </template>
  113 + </ConfigPanelShell>
  114 +</template>
  115 +
  116 +<script>
  117 +import ConfigPanelShell from './config-panel-shell.vue'
  118 +
  119 +const MODULES = [
  120 + { key: 'brand', label: '品牌标识', desc: 'Logo、系统名称与版本', icon: 'el-icon-picture-outline', iconClass: 'module-icon--brand' },
  121 + { key: 'company', label: '公司信息', desc: '版权、联系人与描述', icon: 'el-icon-office-building', iconClass: 'module-icon--company' }
  122 +]
  123 +
  124 +export default {
  125 + name: 'BasicConfigPanel',
  126 + components: { ConfigPanelShell },
  127 + props: {
  128 + form: { type: Object, required: true },
  129 + loading: { type: Boolean, default: false },
  130 + saving: { type: Boolean, default: false },
  131 + logoUploading: { type: Boolean, default: false },
  132 + logoIconUploading: { type: Boolean, default: false },
  133 + logoPreview: { type: Function, required: true },
  134 + onUploadLogo: { type: Function, required: true },
  135 + onUploadLogoIcon: { type: Function, required: true }
  136 + },
  137 + data() {
  138 + return { modules: MODULES }
  139 + }
  140 +}
  141 +</script>
  142 +
  143 +<style lang="scss" scoped>
  144 +.config-section {
  145 + border: 1px solid #ebeef5;
  146 +
  147 + &__header {
  148 + display: flex;
  149 + align-items: center;
  150 + gap: 8px;
  151 + font-weight: 500;
  152 + }
  153 +
  154 + &__tag {
  155 + padding: 0 8px;
  156 + height: 22px;
  157 + line-height: 22px;
  158 + border-radius: 4px;
  159 + background: #f4f4f5;
  160 + color: #909399;
  161 + font-size: 12px;
  162 + font-weight: normal;
  163 + }
  164 +
  165 + &__tip,
  166 + .form-tip {
  167 + margin: 0 0 12px;
  168 + color: #909399;
  169 + font-size: 13px;
  170 + line-height: 1.6;
  171 + }
  172 +}
  173 +
  174 +.module-icon--brand { color: #409EFF; }
  175 +.module-icon--company { color: #67C23A; }
  176 +
  177 +.brand-row .form-tip {
  178 + margin-top: 8px;
  179 + margin-bottom: 0;
  180 +}
  181 +
  182 +.img-uploader {
  183 + display: flex;
  184 + align-items: center;
  185 + gap: 12px;
  186 +}
  187 +
  188 +.single-uploader ::v-deep .el-upload {
  189 + border: 1px dashed #d9d9d9;
  190 + border-radius: 6px;
  191 + cursor: pointer;
  192 + width: 80px;
  193 + height: 80px;
  194 + display: flex;
  195 + align-items: center;
  196 + justify-content: center;
  197 + overflow: hidden;
  198 +
  199 + &:hover {
  200 + border-color: #409EFF;
  201 + }
  202 +}
  203 +
  204 +.single-icon {
  205 + font-size: 22px;
  206 + color: #8c939d;
  207 +}
  208 +
  209 +.single-img {
  210 + width: 80px;
  211 + height: 80px;
  212 + object-fit: contain;
  213 + display: block;
  214 +}
  215 +
  216 +.ncc-content-dark {
  217 + .config-section {
  218 + border-color: rgba(255, 255, 255, 0.08);
  219 +
  220 + &__tag,
  221 + &__tip,
  222 + .form-tip {
  223 + color: rgba(255, 255, 255, 0.55);
  224 + }
  225 +
  226 + &__tag {
  227 + background: rgba(255, 255, 255, 0.08);
  228 + }
  229 + }
  230 +
  231 + .single-uploader ::v-deep .el-upload {
  232 + background-color: #262626;
  233 + border-color: rgba(255, 255, 255, 0.18);
  234 + }
  235 +
  236 + .single-icon {
  237 + color: rgba(255, 255, 255, 0.55);
  238 + }
  239 +}
  240 +</style>
antis-ncc-admin/src/views/system/sysConfig/business-config-panel.vue
1 <template> 1 <template>
2 - <div class="business-config-panel" v-loading="loading">  
3 - <div class="business-config-panel__header">  
4 - <el-alert title="按业务模块配置规则;后续新增配置项将在此页分组展示。" type="info" :closable="false" show-icon />  
5 - <el-button type="primary" size="small" :loading="saving" class="business-config-panel__save"  
6 - @click="$emit('save')">保 存</el-button>  
7 - </div>  
8 -  
9 - <el-collapse v-model="activeModules" class="business-config-panel__collapse">  
10 - <el-collapse-item name="consume">  
11 - <template slot="title">  
12 - <span class="module-title">  
13 - <i class="el-icon-s-order module-icon module-icon--consume"></i>  
14 - 耗卡管理  
15 - </span> 2 + <ConfigPanelShell
  3 + :modules="modules"
  4 + :loading="loading"
  5 + :saving="saving"
  6 + storage-key="businessConfigActiveModule"
  7 + default-module="consume"
  8 + alert-title="左侧选择业务模块,右侧仅展示当前模块配置;保存后全量生效。"
  9 + @save="$emit('save')"
  10 + >
  11 + <template #consume>
  12 + <el-card shadow="never" class="config-section">
  13 + <div slot="header" class="config-section__header">
  14 + <span>小程序端</span>
  15 + <span class="config-section__tag">门店小程序</span>
  16 + </div>
  17 + <p class="config-section__tip">
  18 + 耗卡发生后,超过「可修改天数」将禁止「修改 / 作废 / 重开单」;关闭「允许跨月修改」时,耗卡月份与当前月不同亦禁止操作(即使仍在天数内)。
  19 + </p>
  20 + <el-row :gutter="20">
  21 + <el-col :span="12">
  22 + <el-form-item label="可修改天数" prop="consumeEditableDays" label-width="100px">
  23 + <el-input-number v-model="form.consumeEditableDays" :min="1" :max="365" :precision="0" :step="1"
  24 + controls-position="right" />
  25 + <span class="field-suffix">天</span>
  26 + </el-form-item>
  27 + </el-col>
  28 + <el-col :span="12">
  29 + <el-form-item label="允许跨月修改" prop="consumeAllowCrossMonthEdit" label-width="110px">
  30 + <el-switch v-model="form.consumeAllowCrossMonthEdit" :active-value="1" :inactive-value="0" />
  31 + <span class="field-suffix field-suffix--text">
  32 + {{ Number(form.consumeAllowCrossMonthEdit) === 1 ? '已开启' : '已关闭' }}
  33 + </span>
  34 + </el-form-item>
  35 + </el-col>
  36 + </el-row>
  37 + </el-card>
  38 +
  39 + <el-card shadow="never" class="config-section">
  40 + <div slot="header" class="config-section__header">
  41 + <span>管理后台</span>
  42 + <span class="config-section__tag">会员耗卡</span>
  43 + </div>
  44 + <p class="config-section__tip">
  45 + 分别限制「作废」「删除」可操作的天数;填 0 表示不限制天数。是否显示按钮请在「菜单管理 → 按钮权限」中为角色授权。
  46 + </p>
  47 + <ConfigRuleRow title="作废耗卡" description="标记为无效并级联作废品项、业绩、人次,数据保留可查询。按钮权限编码:btn_cancel。"
  48 + :days.sync="form.adminConsumeVoidEditableDays" />
  49 + <ConfigRuleRow title="删除耗卡" description="物理删除耗卡及关联子表数据,不可恢复。按钮权限编码:btn_remove。"
  50 + :days.sync="form.adminConsumeDeleteEditableDays" />
  51 + <el-row :gutter="20" class="config-section__row">
  52 + <el-col :span="12">
  53 + <el-form-item label="异常耗卡容差" prop="abnormalConsumeAmountTolerance" label-width="110px">
  54 + <el-input-number v-model="form.abnormalConsumeAmountTolerance" :min="0" :max="99999" :precision="2"
  55 + :step="0.01" controls-position="right" />
  56 + <span class="field-suffix">元</span>
  57 + </el-form-item>
  58 + </el-col>
  59 + </el-row>
  60 + <p class="config-section__tip config-section__tip--inline">
  61 + 会员耗卡列表中,消费金额与健康师业绩合计之差额绝对值超过该值时,记为异常耗卡。
  62 + </p>
  63 + </el-card>
16 </template> 64 </template>
17 65
18 - <el-card shadow="never" class="config-section">  
19 - <div slot="header" class="config-section__header">  
20 - <span>小程序端</span>  
21 - <span class="config-section__tag">门店小程序</span>  
22 - </div>  
23 - <p class="config-section__tip">  
24 - 耗卡发生后,超过下列天数将禁止「修改 / 作废 / 重开单」。  
25 - </p>  
26 - <el-row :gutter="20">  
27 - <el-col :span="12">  
28 - <el-form-item label="可修改天数" prop="consumeEditableDays" label-width="100px">  
29 - <el-input-number v-model="form.consumeEditableDays" :min="1" :max="365" :precision="0" :step="1"  
30 - controls-position="right" />  
31 - <span class="field-suffix">天</span>  
32 - </el-form-item>  
33 - </el-col>  
34 - </el-row>  
35 - </el-card>  
36 -  
37 - <el-card shadow="never" class="config-section">  
38 - <div slot="header" class="config-section__header">  
39 - <span>管理后台</span>  
40 - <span class="config-section__tag">会员耗卡</span>  
41 - </div>  
42 - <p class="config-section__tip">  
43 - 分别限制「作废」「删除」可操作的天数;填 0 表示不限制天数。是否显示按钮请在「菜单管理 → 按钮权限」中为角色授权。  
44 - </p>  
45 - <ConfigRuleRow title="作废耗卡" description="标记为无效并级联作废品项、业绩、人次,数据保留可查询。按钮权限编码:btn_cancel。"  
46 - :days.sync="form.adminConsumeVoidEditableDays" />  
47 - <ConfigRuleRow title="删除耗卡" description="物理删除耗卡及关联子表数据,不可恢复。按钮权限编码:btn_remove。"  
48 - :days.sync="form.adminConsumeDeleteEditableDays" />  
49 - <el-row :gutter="20" class="config-section__row">  
50 - <el-col :span="12">  
51 - <el-form-item label="异常耗卡容差" prop="abnormalConsumeAmountTolerance" label-width="110px">  
52 - <el-input-number v-model="form.abnormalConsumeAmountTolerance" :min="0" :max="99999" :precision="2"  
53 - :step="0.01" controls-position="right" />  
54 - <span class="field-suffix">元</span>  
55 - </el-form-item>  
56 - </el-col>  
57 - </el-row>  
58 - <p class="config-section__tip config-section__tip--inline">  
59 - 会员耗卡列表中,消费金额与健康师业绩合计之差额绝对值超过该值时,记为异常耗卡。  
60 - </p>  
61 - </el-card>  
62 - </el-collapse-item>  
63 -  
64 - <el-collapse-item name="tk">  
65 - <template slot="title">  
66 - <span class="module-title">  
67 - <i class="el-icon-s-custom module-icon module-icon--tk"></i>  
68 - 拓客管理  
69 - </span> 66 + <template #tk>
  67 + <el-card shadow="never" class="config-section">
  68 + <div slot="header" class="config-section__header">
  69 + <span>拓客赠送品项</span>
  70 + <span class="config-section__tag">线索拓客</span>
  71 + </div>
  72 + <p class="config-section__tip">
  73 + 拓客成功后会自动创建一笔体验开单,并写入下方所选的这一个品项(金额为 0)。统计、升单、会员类型等场景会排除该品项的零金额开单。
  74 + </p>
  75 + <el-row :gutter="20">
  76 + <el-col :span="16">
  77 + <el-form-item label="赠送品项" prop="tkGiftItemIds" label-width="110px">
  78 + <el-select v-model="tkGiftItemId" filterable remote clearable :remote-method="remoteSearchItems"
  79 + :loading="itemLoading" placeholder="请选择拓客赠送品项" style="width: 100%"
  80 + @visible-change="onItemSelectVisible">
  81 + <el-option v-for="item in itemOptions" :key="item.id" :label="formatItemLabel(item)"
  82 + :value="String(item.id)" />
  83 + </el-select>
  84 + </el-form-item>
  85 + </el-col>
  86 + </el-row>
  87 + </el-card>
70 </template> 88 </template>
71 89
72 - <el-card shadow="never" class="config-section">  
73 - <div slot="header" class="config-section__header">  
74 - <span>拓客赠送品项</span>  
75 - <span class="config-section__tag">线索拓客</span>  
76 - </div>  
77 - <p class="config-section__tip">  
78 - 拓客成功后会自动创建一笔体验开单,并写入下方所选的这一个品项(金额为 0)。统计、升单、会员类型等场景会排除该品项的零金额开单。  
79 - </p>  
80 - <el-row :gutter="20">  
81 - <el-col :span="16">  
82 - <el-form-item label="赠送品项" prop="tkGiftItemIds" label-width="110px">  
83 - <el-select v-model="tkGiftItemId" filterable remote clearable :remote-method="remoteSearchItems"  
84 - :loading="itemLoading" placeholder="请选择拓客赠送品项" style="width: 100%"  
85 - @visible-change="onItemSelectVisible">  
86 - <el-option v-for="item in itemOptions" :key="item.id" :label="formatItemLabel(item)"  
87 - :value="String(item.id)" />  
88 - </el-select>  
89 - </el-form-item>  
90 - </el-col>  
91 - </el-row>  
92 - </el-card>  
93 - </el-collapse-item>  
94 -  
95 - <el-collapse-item name="sleepRemind">  
96 - <template slot="title">  
97 - <span class="module-title">  
98 - <i class="el-icon-bell module-icon module-icon--sleep"></i>  
99 - 沉睡提醒  
100 - </span> 90 + <template #sleepRemind>
  91 + <el-card shadow="never" class="config-section">
  92 + <div slot="header" class="config-section__header">
  93 + <span>门店PC端</span>
  94 + <span class="config-section__tag">会员管理</span>
  95 + </div>
  96 + <p class="config-section__tip">
  97 + 设置沉睡会员提醒天数,超过该天数未消费的会员将在门店PC端显示在沉睡提醒列表中。
  98 + </p>
  99 + <el-row :gutter="20">
  100 + <el-col :span="12">
  101 + <el-form-item label="沉睡天数" prop="sleepRemindDays" label-width="100px">
  102 + <el-input-number v-model="form.sleepRemindDays" :min="1" :max="365" :precision="0" :step="1"
  103 + controls-position="right" />
  104 + <span class="field-suffix">天</span>
  105 + </el-form-item>
  106 + </el-col>
  107 + </el-row>
  108 + </el-card>
  109 +
  110 + <el-card shadow="never" class="config-section">
  111 + <div slot="header" class="config-section__header">
  112 + <span>报表统计</span>
  113 + <span class="config-section__tag">沉睡回店</span>
  114 + </div>
  115 + <p class="config-section__tip">
  116 + 判定会员从沉睡状态回店的天数阈值;首次耗卡按注册日与回店日间隔判断,非首次按上次到店间隔判断。
  117 + </p>
  118 + <el-row :gutter="20">
  119 + <el-col :span="12">
  120 + <el-form-item label="沉睡回店判定天数" prop="sleepReturnDays" label-width="130px">
  121 + <el-input-number v-model="form.sleepReturnDays" :min="1" :max="365" :precision="0" :step="1"
  122 + controls-position="right" />
  123 + <span class="field-suffix">天</span>
  124 + </el-form-item>
  125 + </el-col>
  126 + </el-row>
  127 + </el-card>
101 </template> 128 </template>
102 129
103 - <el-card shadow="never" class="config-section">  
104 - <div slot="header" class="config-section__header">  
105 - <span>门店PC端</span>  
106 - <span class="config-section__tag">会员管理</span>  
107 - </div>  
108 - <p class="config-section__tip">  
109 - 设置沉睡会员提醒天数,超过该天数未消费的会员将在门店PC端显示在沉睡提醒列表中。  
110 - </p>  
111 - <el-row :gutter="20">  
112 - <el-col :span="12">  
113 - <el-form-item label="沉睡天数" prop="sleepRemindDays" label-width="100px">  
114 - <el-input-number v-model="form.sleepRemindDays" :min="1" :max="365" :precision="0" :step="1"  
115 - controls-position="right" />  
116 - <span class="field-suffix">天</span>  
117 - </el-form-item>  
118 - </el-col>  
119 - </el-row>  
120 - </el-card>  
121 -  
122 - <el-card shadow="never" class="config-section">  
123 - <div slot="header" class="config-section__header">  
124 - <span>报表统计</span>  
125 - <span class="config-section__tag">沉睡回店</span>  
126 - </div>  
127 - <p class="config-section__tip">  
128 - 判定会员从沉睡状态回店的天数阈值;首次耗卡按注册日与回店日间隔判断,非首次按上次到店间隔判断。  
129 - </p>  
130 - <el-row :gutter="20">  
131 - <el-col :span="12">  
132 - <el-form-item label="沉睡回店判定天数" prop="sleepReturnDays" label-width="130px">  
133 - <el-input-number v-model="form.sleepReturnDays" :min="1" :max="365" :precision="0" :step="1"  
134 - controls-position="right" />  
135 - <span class="field-suffix">天</span>  
136 - </el-form-item>  
137 - </el-col>  
138 - </el-row>  
139 - </el-card>  
140 - </el-collapse-item>  
141 -  
142 - <el-collapse-item name="trainingCheckin">  
143 - <template slot="title">  
144 - <span class="module-title">  
145 - <i class="el-icon-s-check module-icon module-icon--training"></i>  
146 - 培训签到  
147 - </span> 130 + <template #trainingCheckin>
  131 + <el-card shadow="never" class="config-section">
  132 + <div slot="header" class="config-section__header">
  133 + <span>动态二维码</span>
  134 + <span class="config-section__tag">教室投屏</span>
  135 + </div>
  136 + <p class="config-section__tip">
  137 + 教室门口投屏页二维码自动轮换间隔;学员扫码后须在有效期内完成签到或签退。
  138 + </p>
  139 + <el-row :gutter="20">
  140 + <el-col :span="12">
  141 + <el-form-item label="二维码轮换间隔" prop="trainingCheckinQrRotateMinutes" label-width="120px">
  142 + <el-input-number v-model="form.trainingCheckinQrRotateMinutes" :min="1" :max="60" :precision="0"
  143 + :step="1" controls-position="right" />
  144 + <span class="field-suffix">分钟</span>
  145 + </el-form-item>
  146 + </el-col>
  147 + </el-row>
  148 + </el-card>
148 </template> 149 </template>
149 150
150 - <el-card shadow="never" class="config-section">  
151 - <div slot="header" class="config-section__header">  
152 - <span>动态二维码</span>  
153 - <span class="config-section__tag">教室投屏</span>  
154 - </div>  
155 - <p class="config-section__tip">  
156 - 教室门口投屏页二维码自动轮换间隔;学员扫码后须在有效期内完成签到或签退。  
157 - </p>  
158 - <el-row :gutter="20">  
159 - <el-col :span="12">  
160 - <el-form-item label="二维码轮换间隔" prop="trainingCheckinQrRotateMinutes" label-width="120px">  
161 - <el-input-number v-model="form.trainingCheckinQrRotateMinutes" :min="1" :max="60" :precision="0"  
162 - :step="1" controls-position="right" />  
163 - <span class="field-suffix">分钟</span>  
164 - </el-form-item>  
165 - </el-col>  
166 - </el-row>  
167 - </el-card>  
168 - </el-collapse-item>  
169 -  
170 - <el-collapse-item name="memberTag">  
171 - <template slot="title">  
172 - <span class="module-title">  
173 - <i class="el-icon-collection-tag module-icon module-icon--member"></i>  
174 - 会员标签  
175 - </span> 151 + <template #memberTag>
  152 + <el-card shadow="never" class="config-section">
  153 + <div slot="header" class="config-section__header">
  154 + <span>自定义标签</span>
  155 + <span class="config-section__tag">全公司</span>
  156 + </div>
  157 + <p class="config-section__tip">
  158 + 单名会员可绑定的自定义标签数量上限;三端打标时超过上限将拒绝添加。
  159 + </p>
  160 + <el-row :gutter="20">
  161 + <el-col :span="12">
  162 + <el-form-item label="标签上限" prop="memberCustomTagMaxCount" label-width="100px">
  163 + <el-input-number v-model="form.memberCustomTagMaxCount" :min="1" :max="20" :precision="0" :step="1"
  164 + controls-position="right" />
  165 + <span class="field-suffix">个</span>
  166 + </el-form-item>
  167 + </el-col>
  168 + </el-row>
  169 + </el-card>
176 </template> 170 </template>
177 171
178 - <el-card shadow="never" class="config-section">  
179 - <div slot="header" class="config-section__header">  
180 - <span>自定义标签</span>  
181 - <span class="config-section__tag">全公司</span>  
182 - </div>  
183 - <p class="config-section__tip">  
184 - 单名会员可绑定的自定义标签数量上限;三端打标时超过上限将拒绝添加。  
185 - </p>  
186 - <el-row :gutter="20">  
187 - <el-col :span="12">  
188 - <el-form-item label="标签上限" prop="memberCustomTagMaxCount" label-width="100px">  
189 - <el-input-number v-model="form.memberCustomTagMaxCount" :min="1" :max="20" :precision="0" :step="1"  
190 - controls-position="right" />  
191 - <span class="field-suffix">个</span>  
192 - </el-form-item>  
193 - </el-col>  
194 - </el-row>  
195 - </el-card>  
196 - </el-collapse-item>  
197 -  
198 - <el-collapse-item name="monitorCenter">  
199 - <template slot="title">  
200 - <span class="module-title">  
201 - <i class="el-icon-view module-icon module-icon--monitor"></i>  
202 - 监控中心  
203 - </span> 172 + <template #monitorCenter>
  173 + <el-card shadow="never" class="config-section">
  174 + <div slot="header" class="config-section__header">
  175 + <span>大客卡金异常</span>
  176 + <span class="config-section__tag">金额阈值</span>
  177 + </div>
  178 + <p class="config-section__tip">
  179 + 开单/耗卡/退卡/转卡金额达到对应阈值时,列入「大客卡金异常」监控列表(与重复充值监控并列)。
  180 + </p>
  181 + <el-row :gutter="20">
  182 + <el-col :span="12">
  183 + <el-form-item label="大额开单" prop="largeAmountBillingThreshold" label-width="100px">
  184 + <el-input-number v-model="form.largeAmountBillingThreshold" :min="1" :max="99999999" :precision="2"
  185 + :step="1000" controls-position="right" />
  186 + <span class="field-suffix">元</span>
  187 + </el-form-item>
  188 + </el-col>
  189 + <el-col :span="12">
  190 + <el-form-item label="大额耗卡" prop="largeAmountConsumeThreshold" label-width="100px">
  191 + <el-input-number v-model="form.largeAmountConsumeThreshold" :min="1" :max="99999999" :precision="2"
  192 + :step="1000" controls-position="right" />
  193 + <span class="field-suffix">元</span>
  194 + </el-form-item>
  195 + </el-col>
  196 + <el-col :span="12">
  197 + <el-form-item label="大额退卡" prop="largeAmountRefundThreshold" label-width="100px">
  198 + <el-input-number v-model="form.largeAmountRefundThreshold" :min="1" :max="99999999" :precision="2"
  199 + :step="1000" controls-position="right" />
  200 + <span class="field-suffix">元</span>
  201 + </el-form-item>
  202 + </el-col>
  203 + <el-col :span="12">
  204 + <el-form-item label="大额转卡" prop="largeAmountTransferThreshold" label-width="100px">
  205 + <el-input-number v-model="form.largeAmountTransferThreshold" :min="1" :max="99999999" :precision="2"
  206 + :step="1000" controls-position="right" />
  207 + <span class="field-suffix">元</span>
  208 + </el-form-item>
  209 + </el-col>
  210 + </el-row>
  211 + </el-card>
204 </template> 212 </template>
205 213
206 - <el-card shadow="never" class="config-section">  
207 - <div slot="header" class="config-section__header">  
208 - <span>大客卡金异常</span>  
209 - <span class="config-section__tag">金额阈值</span>  
210 - </div>  
211 - <p class="config-section__tip">  
212 - 开单/耗卡/退卡/转卡金额达到对应阈值时,列入「大客卡金异常」监控列表(与重复充值监控并列)。  
213 - </p>  
214 - <el-row :gutter="20">  
215 - <el-col :span="12">  
216 - <el-form-item label="大额开单" prop="largeAmountBillingThreshold" label-width="100px">  
217 - <el-input-number v-model="form.largeAmountBillingThreshold" :min="1" :max="99999999" :precision="2"  
218 - :step="1000" controls-position="right" />  
219 - <span class="field-suffix">元</span>  
220 - </el-form-item>  
221 - </el-col>  
222 - <el-col :span="12">  
223 - <el-form-item label="大额耗卡" prop="largeAmountConsumeThreshold" label-width="100px">  
224 - <el-input-number v-model="form.largeAmountConsumeThreshold" :min="1" :max="99999999" :precision="2"  
225 - :step="1000" controls-position="right" />  
226 - <span class="field-suffix">元</span>  
227 - </el-form-item>  
228 - </el-col>  
229 - <el-col :span="12">  
230 - <el-form-item label="大额退卡" prop="largeAmountRefundThreshold" label-width="100px">  
231 - <el-input-number v-model="form.largeAmountRefundThreshold" :min="1" :max="99999999" :precision="2"  
232 - :step="1000" controls-position="right" />  
233 - <span class="field-suffix">元</span>  
234 - </el-form-item>  
235 - </el-col>  
236 - <el-col :span="12">  
237 - <el-form-item label="大额转卡" prop="largeAmountTransferThreshold" label-width="100px">  
238 - <el-input-number v-model="form.largeAmountTransferThreshold" :min="1" :max="99999999" :precision="2"  
239 - :step="1000" controls-position="right" />  
240 - <span class="field-suffix">元</span>  
241 - </el-form-item>  
242 - </el-col>  
243 - </el-row>  
244 - </el-card>  
245 - </el-collapse-item>  
246 -  
247 - <el-collapse-item name="goldTriangle">  
248 - <template slot="title">  
249 - <span class="module-title">  
250 - <i class="el-icon-s-data module-icon module-icon--gold"></i>  
251 - 金三角业绩  
252 - </span> 214 + <template #goldTriangle>
  215 + <el-card shadow="never" class="config-section">
  216 + <div slot="header" class="config-section__header">
  217 + <span>员工手机端</span>
  218 + <span class="config-section__tag">健康师</span>
  219 + </div>
  220 + <p class="config-section__tip">
  221 + 控制个人中心「提成缺口」与金三角业绩页「当前提成档」旁的问号说明;关闭后员工端不显示问号及档位弹窗。
  222 + </p>
  223 + <el-row :gutter="20">
  224 + <el-col :span="12">
  225 + <el-form-item label="档位说明问号" prop="commissionTierHelpSwitch" label-width="110px">
  226 + <el-switch v-model="form.commissionTierHelpSwitch" :active-value="1" :inactive-value="0" />
  227 + <span class="field-suffix field-suffix--text">
  228 + {{ Number(form.commissionTierHelpSwitch) === 1 ? '已开启' : '已关闭' }}
  229 + </span>
  230 + </el-form-item>
  231 + </el-col>
  232 + </el-row>
  233 + <commission-tier-help-preview :enabled="Number(form.commissionTierHelpSwitch) === 1" />
  234 + </el-card>
253 </template> 235 </template>
254 -  
255 - <el-card shadow="never" class="config-section">  
256 - <div slot="header" class="config-section__header">  
257 - <span>员工手机端</span>  
258 - <span class="config-section__tag">健康师</span>  
259 - </div>  
260 - <p class="config-section__tip">  
261 - 控制个人中心「提成缺口」与金三角业绩页「当前提成档」旁的问号说明;关闭后员工端不显示问号及档位弹窗。  
262 - </p>  
263 - <el-row :gutter="20">  
264 - <el-col :span="12">  
265 - <el-form-item label="档位说明问号" prop="commissionTierHelpSwitch" label-width="110px">  
266 - <el-switch v-model="form.commissionTierHelpSwitch" :active-value="1" :inactive-value="0" />  
267 - <span class="field-suffix field-suffix--text">  
268 - {{ Number(form.commissionTierHelpSwitch) === 1 ? '已开启' : '已关闭' }}  
269 - </span>  
270 - </el-form-item>  
271 - </el-col>  
272 - </el-row>  
273 - <commission-tier-help-preview :enabled="Number(form.commissionTierHelpSwitch) === 1" />  
274 - </el-card>  
275 - </el-collapse-item>  
276 - </el-collapse>  
277 - </div> 236 + </ConfigPanelShell>
278 </template> 237 </template>
279 238
280 <script> 239 <script>
281 import request from '@/utils/request' 240 import request from '@/utils/request'
  241 +import ConfigPanelShell from './config-panel-shell.vue'
282 import ConfigRuleRow from './config-rule-row' 242 import ConfigRuleRow from './config-rule-row'
283 import CommissionTierHelpPreview from './commission-tier-help-preview' 243 import CommissionTierHelpPreview from './commission-tier-help-preview'
284 244
  245 +const MODULES = [
  246 + { key: 'consume', label: '耗卡管理', desc: '小程序与管理后台耗卡规则', icon: 'el-icon-s-order', iconClass: 'module-icon--consume' },
  247 + { key: 'tk', label: '拓客管理', desc: '拓客赠送品项', icon: 'el-icon-s-custom', iconClass: 'module-icon--tk' },
  248 + { key: 'sleepRemind', label: '沉睡提醒', desc: '门店提醒与回店统计', icon: 'el-icon-bell', iconClass: 'module-icon--sleep' },
  249 + { key: 'trainingCheckin', label: '培训签到', desc: '动态二维码轮换', icon: 'el-icon-s-check', iconClass: 'module-icon--training' },
  250 + { key: 'memberTag', label: '会员标签', desc: '自定义标签上限', icon: 'el-icon-collection-tag', iconClass: 'module-icon--member' },
  251 + { key: 'monitorCenter', label: '监控中心', desc: '大客卡金异常阈值', icon: 'el-icon-view', iconClass: 'module-icon--monitor' },
  252 + { key: 'goldTriangle', label: '金三角业绩', desc: '提成档位说明', icon: 'el-icon-s-data', iconClass: 'module-icon--gold' }
  253 +]
  254 +
285 function parseIdList(raw) { 255 function parseIdList(raw) {
286 if (!raw) return [] 256 if (!raw) return []
287 return String(raw) 257 return String(raw)
@@ -292,7 +262,7 @@ function parseIdList(raw) { @@ -292,7 +262,7 @@ function parseIdList(raw) {
292 262
293 export default { 263 export default {
294 name: 'BusinessConfigPanel', 264 name: 'BusinessConfigPanel',
295 - components: { ConfigRuleRow, CommissionTierHelpPreview }, 265 + components: { ConfigPanelShell, ConfigRuleRow, CommissionTierHelpPreview },
296 props: { 266 props: {
297 form: { 267 form: {
298 type: Object, 268 type: Object,
@@ -309,7 +279,7 @@ export default { @@ -309,7 +279,7 @@ export default {
309 }, 279 },
310 data() { 280 data() {
311 return { 281 return {
312 - activeModules: ['consume', 'tk', 'sleepRemind', 'trainingCheckin', 'memberTag', 'monitorCenter', 'goldTriangle'], 282 + modules: MODULES,
313 itemOptions: [], 283 itemOptions: [],
314 itemLoading: false, 284 itemLoading: false,
315 itemOptionsLoaded: false 285 itemOptionsLoaded: false
@@ -332,7 +302,6 @@ export default { @@ -332,7 +302,6 @@ export default {
332 immediate: true, 302 immediate: true,
333 handler(val) { 303 handler(val) {
334 const ids = parseIdList(val) 304 const ids = parseIdList(val)
335 - // 配置页按单品项展示;若历史存了多个,只回显并保留第一项  
336 if (ids.length > 1) { 305 if (ids.length > 1) {
337 this.$set(this.form, 'tkGiftItemIds', ids[0]) 306 this.$set(this.form, 'tkGiftItemIds', ids[0])
338 } 307 }
@@ -392,14 +361,14 @@ export default { @@ -392,14 +361,14 @@ export default {
392 this.itemOptions.forEach(item => { 361 this.itemOptions.forEach(item => {
393 map[String(item.id)] = item 362 map[String(item.id)] = item
394 }) 363 })
395 - ; (list || []).forEach(item => {  
396 - if (!item || item.id == null) return  
397 - map[String(item.id)] = {  
398 - id: String(item.id),  
399 - xmmc: item.xmmc || item.fullName || String(item.id),  
400 - qt1: item.qt1 || ''  
401 - }  
402 - }) 364 + ;(list || []).forEach(item => {
  365 + if (!item || item.id == null) return
  366 + map[String(item.id)] = {
  367 + id: String(item.id),
  368 + xmmc: item.xmmc || item.fullName || String(item.id),
  369 + qt1: item.qt1 || ''
  370 + }
  371 + })
403 this.itemOptions = Object.keys(map).map(k => map[k]) 372 this.itemOptions = Object.keys(map).map(k => map[k])
404 } 373 }
405 } 374 }
@@ -407,75 +376,14 @@ export default { @@ -407,75 +376,14 @@ export default {
407 </script> 376 </script>
408 377
409 <style lang="scss" scoped> 378 <style lang="scss" scoped>
410 -.business-config-panel {  
411 - padding-top: 4px;  
412 -  
413 - &__header {  
414 - display: flex;  
415 - align-items: flex-start;  
416 - justify-content: space-between;  
417 - gap: 16px;  
418 - margin-bottom: 16px;  
419 - }  
420 -  
421 - &__save {  
422 - flex-shrink: 0;  
423 - width: 100px;  
424 - }  
425 -  
426 - &__collapse {  
427 - border: none;  
428 -  
429 - >>>.el-collapse-item__header {  
430 - height: 48px;  
431 - line-height: 48px;  
432 - font-size: 15px;  
433 - font-weight: 500;  
434 - border-bottom: 1px solid #ebeef5;  
435 - }  
436 -  
437 - >>>.el-collapse-item__wrap {  
438 - border-bottom: none;  
439 - }  
440 -  
441 - >>>.el-collapse-item__content {  
442 - padding: 16px 0 8px;  
443 - }  
444 - }  
445 -}  
446 -  
447 -.module-title {  
448 - display: inline-flex;  
449 - align-items: center;  
450 -}  
451 -  
452 .module-icon { 379 .module-icon {
453 - margin-right: 8px;  
454 - font-size: 16px;  
455 -  
456 - &--consume {  
457 - color: #409EFF;  
458 - }  
459 -  
460 - &--sleep {  
461 - color: #E6A23C;  
462 - }  
463 -  
464 - &--billing {  
465 - color: #67C23A;  
466 - }  
467 -  
468 - &--tk {  
469 - color: #909399;  
470 - }  
471 -  
472 - &--training {  
473 - color: #409EFF;  
474 - }  
475 -  
476 - &--gold {  
477 - color: #43a047;  
478 - } 380 + &--consume { color: #409EFF; }
  381 + &--sleep { color: #E6A23C; }
  382 + &--tk { color: #909399; }
  383 + &--training { color: #409EFF; }
  384 + &--member { color: #9c27b0; }
  385 + &--monitor { color: #f56c6c; }
  386 + &--gold { color: #43a047; }
479 } 387 }
480 388
481 .config-section { 389 .config-section {
@@ -531,15 +439,8 @@ export default { @@ -531,15 +439,8 @@ export default {
531 } 439 }
532 } 440 }
533 441
534 -/* 暗黑:覆盖写死的浅色边框/标签/提示 */  
535 -.ncc-content-dark .business-config-panel {  
536 - &__collapse {  
537 - >>>.el-collapse-item__header {  
538 - border-bottom-color: rgba(255, 255, 255, 0.08);  
539 - color: rgba(255, 255, 255, 0.85);  
540 - }  
541 - }  
542 - 442 +/* 暗黑 */
  443 +.ncc-content-dark {
543 .config-section { 444 .config-section {
544 border-color: rgba(255, 255, 255, 0.08); 445 border-color: rgba(255, 255, 255, 0.08);
545 446
antis-ncc-admin/src/views/system/sysConfig/config-panel-shell.vue 0 → 100644
  1 +<template>
  2 + <div class="config-panel-shell" v-loading="loading">
  3 + <div class="config-panel-shell__header">
  4 + <el-alert :title="alertTitle" :type="alertType" :closable="false" show-icon />
  5 + <div class="config-panel-shell__actions">
  6 + <slot name="header-actions" />
  7 + <el-button
  8 + v-if="showSave"
  9 + type="primary"
  10 + size="small"
  11 + :loading="saving"
  12 + class="config-panel-shell__save"
  13 + @click="$emit('save')"
  14 + >保 存</el-button>
  15 + </div>
  16 + </div>
  17 +
  18 + <div class="config-panel-shell__body" :class="{ 'is-single': modules.length <= 1 }">
  19 + <aside v-if="modules.length > 1" class="config-panel-shell__nav" :class="navThemeClass">
  20 + <button
  21 + v-for="item in modules"
  22 + :key="item.key"
  23 + type="button"
  24 + class="nav-item"
  25 + :class="{ 'is-active': activeModule === item.key }"
  26 + @click="switchModule(item.key)"
  27 + >
  28 + <i :class="[item.icon, 'module-icon', item.iconClass]"></i>
  29 + <span class="nav-item__text">
  30 + <span class="nav-item__label">{{ item.label }}</span>
  31 + <span v-if="item.desc" class="nav-item__desc">{{ item.desc }}</span>
  32 + </span>
  33 + </button>
  34 + </aside>
  35 +
  36 + <section class="config-panel-shell__content">
  37 + <header v-if="showContentHeader" class="content-header">
  38 + <h3 class="content-header__title">
  39 + <i :class="[currentModule.icon, 'module-icon', currentModule.iconClass]"></i>
  40 + {{ currentModule.label }}
  41 + </h3>
  42 + <p v-if="currentModule.desc" class="content-header__desc">{{ currentModule.desc }}</p>
  43 + </header>
  44 + <div class="config-panel-shell__slot">
  45 + <slot :name="activeModule" />
  46 + </div>
  47 + </section>
  48 + </div>
  49 + <slot name="append" />
  50 + </div>
  51 +</template>
  52 +
  53 +<script>
  54 +export default {
  55 + name: 'ConfigPanelShell',
  56 + props: {
  57 + modules: {
  58 + type: Array,
  59 + default: () => []
  60 + },
  61 + loading: {
  62 + type: Boolean,
  63 + default: false
  64 + },
  65 + saving: {
  66 + type: Boolean,
  67 + default: false
  68 + },
  69 + showSave: {
  70 + type: Boolean,
  71 + default: true
  72 + },
  73 + showContentHeader: {
  74 + type: Boolean,
  75 + default: true
  76 + },
  77 + alertTitle: {
  78 + type: String,
  79 + default: '左侧选择模块,右侧仅展示当前配置。'
  80 + },
  81 + alertType: {
  82 + type: String,
  83 + default: 'info'
  84 + },
  85 + storageKey: {
  86 + type: String,
  87 + default: ''
  88 + },
  89 + navTheme: {
  90 + type: String,
  91 + default: 'primary'
  92 + },
  93 + defaultModule: {
  94 + type: String,
  95 + default: ''
  96 + }
  97 + },
  98 + data() {
  99 + return {
  100 + activeModule: this.resolveInitialModule()
  101 + }
  102 + },
  103 + computed: {
  104 + currentModule() {
  105 + return this.modules.find(item => item.key === this.activeModule) || this.modules[0] || { label: '', desc: '', icon: '', iconClass: '' }
  106 + },
  107 + navThemeClass() {
  108 + return `is-theme-${this.navTheme}`
  109 + }
  110 + },
  111 + watch: {
  112 + modules: {
  113 + immediate: true,
  114 + handler(list) {
  115 + if (!list || !list.length) return
  116 + if (!list.some(item => item.key === this.activeModule)) {
  117 + this.activeModule = list[0].key
  118 + }
  119 + }
  120 + }
  121 + },
  122 + methods: {
  123 + resolveInitialModule() {
  124 + const fallback = this.defaultModule || (this.modules[0] && this.modules[0].key) || ''
  125 + if (!this.storageKey) return fallback
  126 + try {
  127 + const stored = localStorage.getItem(this.storageKey)
  128 + if (stored && this.modules.some(item => item.key === stored)) {
  129 + return stored
  130 + }
  131 + } catch (e) {
  132 + // ignore
  133 + }
  134 + return fallback
  135 + },
  136 + switchModule(key) {
  137 + if (this.activeModule === key) return
  138 + this.activeModule = key
  139 + if (this.storageKey) {
  140 + try {
  141 + localStorage.setItem(this.storageKey, key)
  142 + } catch (e) {
  143 + // ignore
  144 + }
  145 + }
  146 + this.$emit('module-change', key)
  147 + this.$nextTick(() => {
  148 + const content = this.$el && this.$el.querySelector('.config-panel-shell__content')
  149 + if (content) content.scrollTop = 0
  150 + })
  151 + }
  152 + }
  153 +}
  154 +</script>
  155 +
  156 +<style lang="scss" scoped>
  157 +.config-panel-shell {
  158 + display: flex;
  159 + flex-direction: column;
  160 + height: calc(100vh - 220px);
  161 + min-height: 480px;
  162 + padding-top: 4px;
  163 +
  164 + &__header {
  165 + display: flex;
  166 + align-items: flex-start;
  167 + justify-content: space-between;
  168 + gap: 16px;
  169 + margin-bottom: 16px;
  170 + flex-shrink: 0;
  171 + }
  172 +
  173 + &__actions {
  174 + display: flex;
  175 + align-items: center;
  176 + gap: 8px;
  177 + flex-shrink: 0;
  178 + }
  179 +
  180 + &__save {
  181 + width: 100px;
  182 + }
  183 +
  184 + &__body {
  185 + display: flex;
  186 + flex: 1;
  187 + min-height: 0;
  188 + border: 1px solid #ebeef5;
  189 + border-radius: 8px;
  190 + overflow: hidden;
  191 + background: #fff;
  192 +
  193 + &.is-single .config-panel-shell__content {
  194 + padding-top: 20px;
  195 + }
  196 + }
  197 +
  198 + &__nav {
  199 + width: 220px;
  200 + flex-shrink: 0;
  201 + padding: 12px 8px;
  202 + border-right: 1px solid #ebeef5;
  203 + background: #fafafa;
  204 + overflow-y: auto;
  205 + }
  206 +
  207 + &__content {
  208 + flex: 1;
  209 + min-width: 0;
  210 + padding: 16px 20px 24px;
  211 + overflow-y: auto;
  212 + }
  213 +
  214 + &__slot {
  215 + min-height: 120px;
  216 + }
  217 +}
  218 +
  219 +.nav-item {
  220 + display: flex;
  221 + align-items: flex-start;
  222 + width: 100%;
  223 + margin-bottom: 6px;
  224 + padding: 10px 12px;
  225 + border: 1px solid transparent;
  226 + border-radius: 8px;
  227 + background: transparent;
  228 + text-align: left;
  229 + cursor: pointer;
  230 + transition: background 0.15s ease, border-color 0.15s ease;
  231 +
  232 + &:last-child {
  233 + margin-bottom: 0;
  234 + }
  235 +
  236 + &__text {
  237 + display: flex;
  238 + flex-direction: column;
  239 + min-width: 0;
  240 + }
  241 +
  242 + &__label {
  243 + color: #303133;
  244 + font-size: 14px;
  245 + font-weight: 500;
  246 + line-height: 20px;
  247 + }
  248 +
  249 + &__desc {
  250 + margin-top: 2px;
  251 + color: #909399;
  252 + font-size: 12px;
  253 + line-height: 18px;
  254 + }
  255 +}
  256 +
  257 +.config-panel-shell__nav.is-theme-primary .nav-item {
  258 + &:hover {
  259 + background: #f0f7ff;
  260 + }
  261 +
  262 + &.is-active {
  263 + background: #ecf5ff;
  264 + border-color: #c6e2ff;
  265 + }
  266 +}
  267 +
  268 +.config-panel-shell__nav.is-theme-warning .nav-item {
  269 + &:hover {
  270 + background: #fdf6ec;
  271 + }
  272 +
  273 + &.is-active {
  274 + background: #fdf6ec;
  275 + border-color: #f5dab1;
  276 + }
  277 +}
  278 +
  279 +.content-header {
  280 + margin-bottom: 16px;
  281 + padding-bottom: 12px;
  282 + border-bottom: 1px solid #ebeef5;
  283 +
  284 + &__title {
  285 + display: flex;
  286 + align-items: center;
  287 + margin: 0;
  288 + color: #303133;
  289 + font-size: 16px;
  290 + font-weight: 600;
  291 + line-height: 24px;
  292 + }
  293 +
  294 + &__desc {
  295 + margin: 6px 0 0;
  296 + color: #909399;
  297 + font-size: 13px;
  298 + line-height: 1.6;
  299 + }
  300 +}
  301 +
  302 +.module-icon {
  303 + margin-right: 8px;
  304 + font-size: 16px;
  305 + flex-shrink: 0;
  306 +}
  307 +
  308 +.ncc-content-dark .config-panel-shell {
  309 + &__body {
  310 + border-color: rgba(255, 255, 255, 0.08);
  311 + background: transparent;
  312 + }
  313 +
  314 + &__nav {
  315 + border-right-color: rgba(255, 255, 255, 0.08);
  316 + background: rgba(255, 255, 255, 0.03);
  317 + }
  318 +
  319 + .nav-item__label {
  320 + color: rgba(255, 255, 255, 0.85);
  321 + }
  322 +
  323 + .nav-item__desc {
  324 + color: rgba(255, 255, 255, 0.55);
  325 + }
  326 +
  327 + .content-header {
  328 + border-bottom-color: rgba(255, 255, 255, 0.08);
  329 +
  330 + &__title {
  331 + color: rgba(255, 255, 255, 0.85);
  332 + }
  333 +
  334 + &__desc {
  335 + color: rgba(255, 255, 255, 0.55);
  336 + }
  337 + }
  338 +}
  339 +</style>
antis-ncc-admin/src/views/system/sysConfig/index.vue
@@ -3,263 +3,63 @@ @@ -3,263 +3,63 @@
3 <el-form ref="baseForm" :model="baseForm" label-width="120px"> 3 <el-form ref="baseForm" :model="baseForm" label-width="120px">
4 <el-tabs v-model="activeName" type="border-card" class="NCC-el_tabs"> 4 <el-tabs v-model="activeName" type="border-card" class="NCC-el_tabs">
5 <el-tab-pane label="基本设置" name="first"> 5 <el-tab-pane label="基本设置" name="first">
6 - <div class="basic-settings">  
7 - <el-divider content-position="left">品牌标识</el-divider>  
8 - <el-row :gutter="24" class="brand-row">  
9 - <el-col :xs="24" :sm="12" :md="8">  
10 - <el-form-item label="系统 Logo">  
11 - <div class="img-uploader" v-loading="logoUploading">  
12 - <el-upload class="single-uploader" :show-file-list="false" action="#" accept="image/*"  
13 - :http-request="uploadSysLogo">  
14 - <img v-if="baseForm.sysLogo" :src="logoPreview(baseForm.sysLogo)" class="single-img">  
15 - <i v-else class="el-icon-plus single-icon"></i>  
16 - </el-upload>  
17 - <el-button v-if="baseForm.sysLogo" type="text" class="NCC-table-delBtn"  
18 - @click="baseForm.sysLogo = ''">移除</el-button>  
19 - </div>  
20 - <p class="form-tip">侧栏展开时显示,建议 32×32 或等比透明图</p>  
21 - </el-form-item>  
22 - </el-col>  
23 - <el-col :xs="24" :sm="12" :md="8">  
24 - <el-form-item label="折叠图标">  
25 - <div class="img-uploader" v-loading="logoIconUploading">  
26 - <el-upload class="single-uploader" :show-file-list="false" action="#" accept="image/*"  
27 - :http-request="uploadSysLogoIcon">  
28 - <img v-if="baseForm.sysLogoIcon" :src="logoPreview(baseForm.sysLogoIcon)" class="single-img">  
29 - <i v-else class="el-icon-plus single-icon"></i>  
30 - </el-upload>  
31 - <el-button v-if="baseForm.sysLogoIcon" type="text" class="NCC-table-delBtn"  
32 - @click="baseForm.sysLogoIcon = ''">移除</el-button>  
33 - </div>  
34 - <p class="form-tip">可选;侧栏折叠时显示,不传则使用系统 Logo</p>  
35 - </el-form-item>  
36 - </el-col>  
37 - </el-row>  
38 -  
39 - <el-divider content-position="left">基础信息</el-divider>  
40 - <el-row :gutter="24">  
41 - <el-col :span="12">  
42 - <el-form-item label="系统名称" prop="sysName">  
43 - <el-input v-model="baseForm.sysName" clearable placeholder="系统名称" />  
44 - </el-form-item>  
45 - </el-col>  
46 - <el-col :span="12">  
47 - <el-form-item label="系统版本" prop="sysVersion">  
48 - <el-input v-model="baseForm.sysVersion" clearable placeholder="系统版本" />  
49 - </el-form-item>  
50 - </el-col>  
51 - <el-col :span="12">  
52 - <el-form-item label="公司名称" prop="companyName">  
53 - <el-input v-model="baseForm.companyName" clearable placeholder="公司名称" />  
54 - </el-form-item>  
55 - </el-col>  
56 - <el-col :span="12">  
57 - <el-form-item label="公司简称" prop="companyCode">  
58 - <el-input v-model="baseForm.companyCode" clearable placeholder="公司简称" />  
59 - </el-form-item>  
60 - </el-col>  
61 - <el-col :span="12">  
62 - <el-form-item label="版权信息" prop="copyright">  
63 - <el-input v-model="baseForm.copyright" clearable placeholder="版权信息" />  
64 - </el-form-item>  
65 - </el-col>  
66 - <el-col :span="12">  
67 - <el-form-item label="公司法人" prop="companyContacts">  
68 - <el-input v-model="baseForm.companyContacts" clearable placeholder="公司法人" />  
69 - </el-form-item>  
70 - </el-col>  
71 - <el-col :span="12">  
72 - <el-form-item label="公司电话" prop="companyTelePhone">  
73 - <el-input v-model="baseForm.companyTelePhone" clearable placeholder="公司电话" />  
74 - </el-form-item>  
75 - </el-col>  
76 - <el-col :span="12">  
77 - <el-form-item label="公司邮箱" prop="companyEmail">  
78 - <el-input v-model="baseForm.companyEmail" clearable placeholder="公司邮箱" />  
79 - </el-form-item>  
80 - </el-col>  
81 - <el-col :span="24">  
82 - <el-form-item label="公司地址" prop="companyAddress">  
83 - <el-input v-model="baseForm.companyAddress" clearable placeholder="公司地址" />  
84 - </el-form-item>  
85 - </el-col>  
86 - <el-col :span="24">  
87 - <el-form-item label="系统描述" prop="sysDescription">  
88 - <el-input v-model="baseForm.sysDescription" type="textarea" :autosize="{ minRows: 4, maxRows: 10 }"  
89 - placeholder="系统描述" />  
90 - </el-form-item>  
91 - </el-col>  
92 - </el-row>  
93 -  
94 - <div class="form-actions">  
95 - <el-button type="primary" size="small" :loading="btnLoading" class="saveBtn" @click="submitForm()">保  
96 - 存</el-button>  
97 - </div> 6 + <div class="config-tab-panel">
  7 + <BasicConfigPanel
  8 + :form="baseForm"
  9 + :loading="listLoading"
  10 + :saving="btnLoading"
  11 + :logo-uploading="logoUploading"
  12 + :logo-icon-uploading="logoIconUploading"
  13 + :logo-preview="logoPreview"
  14 + :on-upload-logo="uploadSysLogo"
  15 + :on-upload-logo-icon="uploadSysLogoIcon"
  16 + @save="submitForm"
  17 + />
98 </div> 18 </div>
99 </el-tab-pane> 19 </el-tab-pane>
100 <el-tab-pane label="安全设置" name="second"> 20 <el-tab-pane label="安全设置" name="second">
101 - <el-alert title="注意:系统登录安全、黑名单IP限制" type="warning" :closable="false" show-icon />  
102 - <el-row>  
103 - <el-col :span="18">  
104 - <el-divider content-position="left">登录设置</el-divider>  
105 - <el-form-item label="单一登录">  
106 - <el-select v-model="baseForm.singleLogin" placeholder="请选择">  
107 - <el-option label="后登录踢出先登录" value="1" />  
108 - </el-select>  
109 - </el-form-item>  
110 - <el-form-item label="超时登出">  
111 - <el-input-number v-model="baseForm.tokenTimeout" :min="1" :precision="0" :step="1"  
112 - controls-position="right" /> 分钟  
113 - </el-form-item>  
114 - <el-form-item label="上次登录">  
115 - <el-switch v-model="baseForm.lastLoginTimeSwitch" :active-value="1" :inactive-value="0" />  
116 - </el-form-item>  
117 - <el-form-item>  
118 - <el-card class="box-card" shadow="never" style="width: 300px;" :body-style="{ padding: '0px 20px' }">  
119 - <div slot="header">  
120 - <span>上次登录信息提示</span>  
121 - <i style="float: right; padding: 11px 0" class="el-icon-close" />  
122 - </div>  
123 - <div class="item">  
124 - <p>时间:2018-10-17 12:40</p>  
125 - <p>地点:上海市</p>  
126 - <p>IP:255.255.0.0</p>  
127 - </div>  
128 - </el-card>  
129 - </el-form-item>  
130 - <el-divider content-position="left">访问设置</el-divider>  
131 - <el-form-item label="开启验证">  
132 - <el-switch v-model="baseForm.whitelistSwitch" :active-value="1" :inactive-value="0" />  
133 - </el-form-item>  
134 - <el-form-item label="页面水印">  
135 - <el-switch v-model="baseForm.watermarkSwitch" :active-value="1" :inactive-value="0"  
136 - @change="onWatermarkSwitchChange" />  
137 - <span class="form-tip" style="margin-left: 12px;">开启后管理后台与员工手机端均显示「部门、姓名、电话、时间」水印(请点保存持久化)</span>  
138 - </el-form-item>  
139 - <el-form-item label="PC水印深浅" v-show="Number(baseForm.watermarkSwitch) === 1">  
140 - <div class="opacity-row">  
141 - <el-slider v-model="baseForm.watermarkOpacityPc" :min="1" :max="100" :step="1" show-input  
142 - input-size="small" style="width: 360px;" @change="onWatermarkOpacityChange" />  
143 - <span class="form-tip">仅影响管理后台,数值越大水印越深(默认 10)</span>  
144 - </div>  
145 - </el-form-item>  
146 - <el-form-item label="手机水印深浅" v-show="Number(baseForm.watermarkSwitch) === 1">  
147 - <div class="opacity-row">  
148 - <el-slider v-model="baseForm.watermarkOpacityApp" :min="1" :max="100" :step="1" show-input  
149 - input-size="small" style="width: 360px;" />  
150 - <span class="form-tip">仅影响员工手机端,数值越大水印越深(默认 14)</span>  
151 - </div>  
152 - </el-form-item>  
153 - <el-divider content-position="left">隐私设置</el-divider>  
154 - <el-form-item label="手机号脱敏">  
155 - <el-switch v-model="baseForm.privacyMobileMask" :active-value="1" :inactive-value="0"  
156 - @change="onPrivacyMobileMaskChange" />  
157 - <span class="form-tip" style="margin-left: 12px;">关闭时发送日志等展示明文手机号;开启后全站列表/详情/发送日志按脱敏规则展示(请点保存持久化)</span>  
158 - </el-form-item>  
159 - <el-form-item label="允许访问IP">  
160 - <el-input type="textarea" :autosize="{ minRows: 3, maxRows: 10 }" v-model="baseForm.whiteListIp"  
161 - placeholder="允许访问IP" />  
162 - </el-form-item>  
163 - <el-form-item>  
164 - <el-button type="primary" size="small" :loading="btnLoading" class="saveBtn" @click="submitForm()">保  
165 - 存</el-button>  
166 - </el-form-item>  
167 - </el-col>  
168 - </el-row> 21 + <div class="config-tab-panel">
  22 + <SecurityConfigPanel
  23 + :form="baseForm"
  24 + :loading="listLoading"
  25 + :saving="btnLoading"
  26 + @save="submitForm"
  27 + @watermark-switch-change="onWatermarkSwitchChange"
  28 + @watermark-opacity-change="onWatermarkOpacityChange"
  29 + @privacy-mobile-mask-change="onPrivacyMobileMaskChange"
  30 + />
  31 + </div>
169 </el-tab-pane> 32 </el-tab-pane>
170 <el-tab-pane label="短信设置" name="sms"> 33 <el-tab-pane label="短信设置" name="sms">
171 - <sms-config-panel /> 34 + <div class="config-tab-panel">
  35 + <sms-config-panel />
  36 + </div>
172 </el-tab-pane> 37 </el-tab-pane>
173 <el-tab-pane label="AI 配置" name="third"> 38 <el-tab-pane label="AI 配置" name="third">
174 - <el-alert title="当前主推通义千问(阿里云 DashScope)。语音识别可与文本对话使用不同密钥;语音识别结果再走「文本总结」时使用下方对话配置。" type="info"  
175 - :closable="false" show-icon />  
176 - <el-row style="margin-top: 16px">  
177 - <el-col :span="18">  
178 - <el-divider content-position="left">语音识别</el-divider>  
179 - <p class="form-tip">  
180 - Paraformer<strong>异步转写(录音文件识别)</strong>专用;留空则使用官方:<code>{{ defaultParaformerSubmitUrl }}</code>。  
181 - <strong>禁止</strong>填写下方「对话」用的 <code>compatible-mode/v1</code>,否则会提交失败。  
182 - </p>  
183 - <el-form-item label="语音服务地址" prop="aiSpeechApiUrl">  
184 - <el-input v-model="baseForm.aiSpeechApiUrl" clearable  
185 - placeholder="留空即可;请勿填 compatible-mode/v1(那是对话网关)" />  
186 - </el-form-item>  
187 - <el-form-item label="语音 API Key" prop="aiSpeechApiKey">  
188 - <el-input v-model="baseForm.aiSpeechApiKey" show-password clearable autocomplete="new-password"  
189 - placeholder="语音识别使用的 API Key" />  
190 - </el-form-item>  
191 - <p class="form-tip">  
192 - 与百炼「录音文件识别」里的 <code>model</code> 一致(<strong>Paraformer 系列</strong>);留空则默认 <code>paraformer-v2</code>。  
193 - <strong>禁止</strong>填 <code>qwen*</code>、实时/口译类模型名(如  
194 - <code>qwen3-livetranslate-flash-realtime-2025-09-22</code>),那些属于另一条产品线,异步文件识别用不了。  
195 - </p>  
196 - <el-form-item label="语音识别模型" prop="aiSpeechModel">  
197 - <el-input v-model="baseForm.aiSpeechModel" clearable  
198 - placeholder="paraformer-v2 或 paraformer-8k-v2;勿填 qwen / livetranslate / realtime" />  
199 - </el-form-item>  
200 - <el-divider content-position="left">对话与文本总结</el-divider>  
201 - <p class="form-tip">OpenAI 兼容模式:用于文案总结等,需与下方模型名搭配(如兼容模式接入千问)。</p>  
202 - <el-form-item label="对话接口地址" prop="aiChatApiUrl">  
203 - <el-input v-model="baseForm.aiChatApiUrl" clearable  
204 - placeholder="示例:https://dashscope.aliyuncs.com/compatible-mode/v1" />  
205 - </el-form-item>  
206 - <el-form-item label="模型名称" prop="aiChatModel">  
207 - <el-input v-model="baseForm.aiChatModel" clearable placeholder="如 qwen-plus、qwen-turbo" />  
208 - </el-form-item>  
209 - <el-form-item label="文本 API Key" prop="aiChatApiKey">  
210 - <el-input v-model="baseForm.aiChatApiKey" show-password clearable autocomplete="new-password"  
211 - placeholder="可与语音使用同一密钥或单独的子 Key" />  
212 - </el-form-item>  
213 - <el-divider content-position="left">备注归纳提示词</el-divider>  
214 - <p class="form-tip">发给千问的 <strong>system</strong> 角色,用来约束「删掉废话、保留关键」。留空则用系统内置默认(美业健康师备注风格)。</p>  
215 - <el-form-item label="归纳提示词" prop="aiSummarizeSystemPrompt">  
216 - <el-input v-model="baseForm.aiSummarizeSystemPrompt" type="textarea"  
217 - :autosize="{ minRows: 5, maxRows: 18 }" placeholder='例如:你是一名资深美业咨询师,请将下方口语转写整理为一句专业、可追溯的工单备注……' />  
218 - </el-form-item>  
219 - <el-form-item>  
220 - <el-button type="primary" size="small" :loading="btnLoading" class="saveBtn" @click="submitForm()">保  
221 - 存</el-button>  
222 - </el-form-item>  
223 - </el-col>  
224 - </el-row> 39 + <div class="config-tab-panel">
  40 + <AiConfigPanel
  41 + :form="baseForm"
  42 + :loading="listLoading"
  43 + :saving="btnLoading"
  44 + :default-paraformer-submit-url="defaultParaformerSubmitUrl"
  45 + @save="submitForm"
  46 + />
  47 + </div>
225 </el-tab-pane> 48 </el-tab-pane>
226 <el-tab-pane label="业务配置" name="business"> 49 <el-tab-pane label="业务配置" name="business">
227 - <BusinessConfigPanel :form="baseForm" :loading="listLoading" :saving="btnLoading" @save="submitForm" /> 50 + <div class="config-tab-panel">
  51 + <BusinessConfigPanel :form="baseForm" :loading="listLoading" :saving="btnLoading" @save="submitForm" />
  52 + </div>
228 </el-tab-pane> 53 </el-tab-pane>
229 <el-tab-pane label="会员商城支付" name="mallPay"> 54 <el-tab-pane label="会员商城支付" name="mallPay">
230 - <el-alert title="会员商城微信支付参数。未开启或参数缺失时,小程序下单走模拟支付(MockPay)便于联调;正式收款需填写商户参数并开启。" type="info" :closable="false"  
231 - show-icon />  
232 - <el-row style="margin-top: 16px">  
233 - <el-col :span="18">  
234 - <el-divider content-position="left">微信支付配置</el-divider>  
235 - <el-form-item label="启用支付">  
236 - <el-switch v-model="mallPayForm.PayEnabled" active-value="1" inactive-value="0" />  
237 - <span class="form-tip" style="margin-left: 12px;">关闭时小程序下单使用模拟支付</span>  
238 - </el-form-item>  
239 - <el-form-item label="小程序 AppId">  
240 - <el-input v-model="mallPayForm.AppId" clearable placeholder="wx 开头的小程序 AppId" />  
241 - </el-form-item>  
242 - <el-form-item label="小程序 AppSecret">  
243 - <el-input v-model="mallPayForm.AppSecret" show-password clearable autocomplete="new-password"  
244 - placeholder="小程序 AppSecret" />  
245 - </el-form-item>  
246 - <el-form-item label="微信商户号">  
247 - <el-input v-model="mallPayForm.MchId" clearable placeholder="微信支付商户号 mchid" />  
248 - </el-form-item>  
249 - <el-form-item label="APIv3 密钥">  
250 - <el-input v-model="mallPayForm.ApiV3Key" show-password clearable autocomplete="new-password"  
251 - placeholder="微信支付 APIv3 密钥" />  
252 - </el-form-item>  
253 - <el-form-item label="支付回调地址">  
254 - <el-input v-model="mallPayForm.NotifyUrl" clearable  
255 - placeholder="如 https://域名/api/Extend/LqMallOrder/PayNotify" />  
256 - </el-form-item>  
257 - <el-form-item>  
258 - <el-button type="primary" size="small" :loading="mallPayLoading" class="saveBtn"  
259 - @click="saveMallPay()">保 存</el-button>  
260 - </el-form-item>  
261 - </el-col>  
262 - </el-row> 55 + <div class="config-tab-panel">
  56 + <MallPayConfigPanel
  57 + v-if="mallPayReady"
  58 + :form="mallPayForm"
  59 + :saving="mallPayLoading"
  60 + @save="saveMallPay"
  61 + />
  62 + </div>
263 </el-tab-pane> 63 </el-tab-pane>
264 </el-tabs> 64 </el-tabs>
265 </el-form> 65 </el-form>
@@ -273,6 +73,10 @@ import { @@ -273,6 +73,10 @@ import {
273 } from '@/api/system/sysConfig' 73 } from '@/api/system/sysConfig'
274 import { getPayConfig, savePayConfig } from '@/api/extend/mallConfig' 74 import { getPayConfig, savePayConfig } from '@/api/extend/mallConfig'
275 import BusinessConfigPanel from './business-config-panel' 75 import BusinessConfigPanel from './business-config-panel'
  76 +import BasicConfigPanel from './basic-config-panel'
  77 +import SecurityConfigPanel from './security-config-panel'
  78 +import AiConfigPanel from './ai-config-panel'
  79 +import MallPayConfigPanel from './mall-pay-config-panel'
276 import SmsConfigPanel from './sms-config-panel' 80 import SmsConfigPanel from './sms-config-panel'
277 import { normalizeBusinessConfigFields } from '@/utils/consume-admin-policy' 81 import { normalizeBusinessConfigFields } from '@/utils/consume-admin-policy'
278 import { ossUploadFile } from '@/utils/oss-upload' 82 import { ossUploadFile } from '@/utils/oss-upload'
@@ -281,7 +85,14 @@ import getPageTitle from &#39;@/utils/get-page-title&#39; @@ -281,7 +85,14 @@ import getPageTitle from &#39;@/utils/get-page-title&#39;
281 85
282 export default { 86 export default {
283 name: 'system-sysConfig', 87 name: 'system-sysConfig',
284 - components: { BusinessConfigPanel, SmsConfigPanel }, 88 + components: {
  89 + BusinessConfigPanel,
  90 + BasicConfigPanel,
  91 + SecurityConfigPanel,
  92 + AiConfigPanel,
  93 + MallPayConfigPanel,
  94 + SmsConfigPanel
  95 + },
285 data() { 96 data() {
286 return { 97 return {
287 defaultParaformerSubmitUrl: 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription', 98 defaultParaformerSubmitUrl: 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription',
@@ -291,6 +102,7 @@ export default { @@ -291,6 +102,7 @@ export default {
291 logoUploading: false, 102 logoUploading: false,
292 logoIconUploading: false, 103 logoIconUploading: false,
293 mallPayLoading: false, 104 mallPayLoading: false,
  105 + mallPayReady: false,
294 mallPayForm: { 106 mallPayForm: {
295 MchId: '', 107 MchId: '',
296 ApiV3Key: '', 108 ApiV3Key: '',
@@ -354,6 +166,7 @@ export default { @@ -354,6 +166,7 @@ export default {
354 aiChatApiKey: '', 166 aiChatApiKey: '',
355 aiSummarizeSystemPrompt: '', 167 aiSummarizeSystemPrompt: '',
356 consumeEditableDays: 3, 168 consumeEditableDays: 3,
  169 + consumeAllowCrossMonthEdit: 0,
357 adminConsumeVoidEditableDays: 0, 170 adminConsumeVoidEditableDays: 0,
358 adminConsumeDeleteEditableDays: 0, 171 adminConsumeDeleteEditableDays: 0,
359 abnormalConsumeAmountTolerance: 0.01, 172 abnormalConsumeAmountTolerance: 0.01,
@@ -377,6 +190,7 @@ export default { @@ -377,6 +190,7 @@ export default {
377 methods: { 190 methods: {
378 // 加载会员商城支付配置(Category=MallPayment) 191 // 加载会员商城支付配置(Category=MallPayment)
379 initMallPayConfig() { 192 initMallPayConfig() {
  193 + this.mallPayLoading = true
380 getPayConfig().then(res => { 194 getPayConfig().then(res => {
381 const data = (res && res.data) || {} 195 const data = (res && res.data) || {}
382 this.mallPayForm = { 196 this.mallPayForm = {
@@ -387,7 +201,10 @@ export default { @@ -387,7 +201,10 @@ export default {
387 NotifyUrl: data.notifyUrl || '', 201 NotifyUrl: data.notifyUrl || '',
388 PayEnabled: data.payEnabled === '1' || data.payEnabled === 1 ? '1' : '0' 202 PayEnabled: data.payEnabled === '1' || data.payEnabled === 1 ? '1' : '0'
389 } 203 }
390 - }).catch(() => { }) 204 + }).catch(() => { }).finally(() => {
  205 + this.mallPayLoading = false
  206 + this.mallPayReady = true
  207 + })
391 }, 208 },
392 // 保存会员商城支付配置 209 // 保存会员商城支付配置
393 saveMallPay() { 210 saveMallPay() {
@@ -421,6 +238,9 @@ export default { @@ -421,6 +238,9 @@ export default {
421 this.baseForm.commissionTierHelpSwitch = this.baseForm.commissionTierHelpSwitch == null 238 this.baseForm.commissionTierHelpSwitch = this.baseForm.commissionTierHelpSwitch == null
422 ? 1 239 ? 1
423 : (Number(this.baseForm.commissionTierHelpSwitch) === 1 ? 1 : 0) 240 : (Number(this.baseForm.commissionTierHelpSwitch) === 1 ? 1 : 0)
  241 + this.baseForm.consumeAllowCrossMonthEdit = this.baseForm.consumeAllowCrossMonthEdit == null
  242 + ? 0
  243 + : (Number(this.baseForm.consumeAllowCrossMonthEdit) === 1 ? 1 : 0)
424 this.baseForm = normalizeBusinessConfigFields(this.baseForm) 244 this.baseForm = normalizeBusinessConfigFields(this.baseForm)
425 this.syncSysBrand() 245 this.syncSysBrand()
426 this.listLoading = false 246 this.listLoading = false
@@ -493,6 +313,9 @@ export default { @@ -493,6 +313,9 @@ export default {
493 payload.commissionTierHelpSwitch = payload.commissionTierHelpSwitch == null 313 payload.commissionTierHelpSwitch = payload.commissionTierHelpSwitch == null
494 ? 1 314 ? 1
495 : (Number(payload.commissionTierHelpSwitch) === 1 ? 1 : 0) 315 : (Number(payload.commissionTierHelpSwitch) === 1 ? 1 : 0)
  316 + payload.consumeAllowCrossMonthEdit = payload.consumeAllowCrossMonthEdit == null
  317 + ? 0
  318 + : (Number(payload.consumeAllowCrossMonthEdit) === 1 ? 1 : 0)
496 updateSystemConfig(payload).then(res => { 319 updateSystemConfig(payload).then(res => {
497 this.syncSysBrand() 320 this.syncSysBrand()
498 this.$message({ 321 this.$message({
@@ -525,115 +348,23 @@ export default { @@ -525,115 +348,23 @@ export default {
525 } 348 }
526 349
527 >>>.el-tabs--border-card>.el-tabs__content { 350 >>>.el-tabs--border-card>.el-tabs__content {
528 - padding: 30px 36px 10px 36px;  
529 - }  
530 -  
531 - .saveBtn {  
532 - width: 100px;  
533 - }  
534 -  
535 - .basic-settings {  
536 - max-width: 1100px;  
537 - }  
538 -  
539 - .basic-settings>>>.el-divider {  
540 - margin: 8px 0 20px;  
541 - }  
542 -  
543 - .basic-settings>>>.el-divider:first-child {  
544 - margin-top: 0;  
545 - }  
546 -  
547 - .brand-row {  
548 - margin-bottom: 4px;  
549 - }  
550 -  
551 - .brand-row .el-form-item {  
552 - margin-bottom: 8px;  
553 - }  
554 -  
555 - .brand-row .form-tip {  
556 - margin-top: 8px;  
557 - margin-bottom: 0;  
558 - min-height: 40px;  
559 - }  
560 -  
561 - .form-actions {  
562 - margin-top: 8px;  
563 - padding-left: 120px;  
564 - }  
565 -  
566 - .form-tip {  
567 - color: #909399;  
568 - font-size: 13px;  
569 - line-height: 1.6;  
570 - margin: 0 0 12px 0;  
571 - padding-left: 0;  
572 - }  
573 -  
574 - .opacity-row {  
575 - display: flex;  
576 - align-items: center;  
577 - flex-wrap: wrap;  
578 - gap: 12px;  
579 - }  
580 -  
581 - .opacity-row .form-tip {  
582 - margin: 0;  
583 - }  
584 -  
585 - .img-uploader {  
586 - display: flex;  
587 - align-items: center;  
588 - gap: 12px;  
589 - }  
590 -  
591 - .single-uploader ::v-deep .el-upload {  
592 - border: 1px dashed #d9d9d9;  
593 - border-radius: 6px;  
594 - cursor: pointer;  
595 - width: 80px;  
596 - height: 80px;  
597 - display: flex;  
598 - align-items: center;  
599 - justify-content: center; 351 + padding: 16px 20px 10px;
  352 + height: calc(100% - 40px);
600 overflow: hidden; 353 overflow: hidden;
601 -  
602 - &:hover {  
603 - border-color: #409EFF;  
604 - }  
605 } 354 }
606 355
607 - .single-icon {  
608 - font-size: 22px;  
609 - color: #8c939d; 356 + >>>.el-tab-pane {
  357 + height: 100%;
610 } 358 }
611 359
612 - .single-img {  
613 - width: 80px;  
614 - height: 80px;  
615 - object-fit: contain;  
616 - display: block;  
617 - }  
618 -}  
619 -  
620 -/* 暗黑:覆盖本页写死的浅色上传/提示(祖先 .ncc-content-dark) */  
621 -.ncc-content-dark .systemConfig {  
622 - .form-tip {  
623 - color: rgba(255, 255, 255, 0.55);  
624 - }  
625 -  
626 - .single-uploader ::v-deep .el-upload {  
627 - background-color: #262626;  
628 - border-color: rgba(255, 255, 255, 0.18);  
629 -  
630 - &:hover {  
631 - border-color: #1890ff;  
632 - } 360 + .config-tab-panel {
  361 + margin: 0;
  362 + height: 100%;
  363 + min-height: calc(100vh - 200px);
633 } 364 }
634 365
635 - .single-icon {  
636 - color: rgba(255, 255, 255, 0.55); 366 + .saveBtn {
  367 + width: 100px;
637 } 368 }
638 } 369 }
639 </style> 370 </style>
antis-ncc-admin/src/views/system/sysConfig/mall-pay-config-panel.vue 0 → 100644
  1 +<template>
  2 + <ConfigPanelShell
  3 + :modules="modules"
  4 + :loading="loading"
  5 + :saving="saving"
  6 + :show-content-header="false"
  7 + storage-key="mallPayConfigActiveModule"
  8 + alert-title="会员商城微信支付参数。未开启或参数缺失时,小程序下单走模拟支付(MockPay)便于联调。"
  9 + alert-type="info"
  10 + @save="$emit('save')"
  11 + >
  12 + <template #payment>
  13 + <el-card shadow="never" class="config-section">
  14 + <div slot="header" class="config-section__header">
  15 + <span>微信支付</span>
  16 + <span class="config-section__tag">会员商城</span>
  17 + </div>
  18 + <p class="config-section__tip">
  19 + 正式收款需填写商户参数并开启;关闭时小程序下单使用模拟支付。
  20 + </p>
  21 + <el-form-item label="启用支付">
  22 + <el-switch v-model="form.PayEnabled" active-value="1" inactive-value="0" />
  23 + <span class="field-suffix field-suffix--text">
  24 + {{ form.PayEnabled === '1' ? '已开启' : '已关闭(模拟支付)' }}
  25 + </span>
  26 + </el-form-item>
  27 + <el-row :gutter="20">
  28 + <el-col :span="12">
  29 + <el-form-item label="小程序 AppId">
  30 + <el-input v-model="form.AppId" clearable placeholder="wx 开头的小程序 AppId" />
  31 + </el-form-item>
  32 + </el-col>
  33 + <el-col :span="12">
  34 + <el-form-item label="微信商户号">
  35 + <el-input v-model="form.MchId" clearable placeholder="微信支付商户号 mchid" />
  36 + </el-form-item>
  37 + </el-col>
  38 + <el-col :span="12">
  39 + <el-form-item label="小程序 AppSecret">
  40 + <el-input v-model="form.AppSecret" show-password clearable autocomplete="new-password"
  41 + placeholder="小程序 AppSecret" />
  42 + </el-form-item>
  43 + </el-col>
  44 + <el-col :span="12">
  45 + <el-form-item label="APIv3 密钥">
  46 + <el-input v-model="form.ApiV3Key" show-password clearable autocomplete="new-password"
  47 + placeholder="微信支付 APIv3 密钥" />
  48 + </el-form-item>
  49 + </el-col>
  50 + <el-col :span="24">
  51 + <el-form-item label="支付回调地址">
  52 + <el-input v-model="form.NotifyUrl" clearable
  53 + placeholder="如 https://域名/api/Extend/LqMallOrder/PayNotify" />
  54 + </el-form-item>
  55 + </el-col>
  56 + </el-row>
  57 + </el-card>
  58 + </template>
  59 + </ConfigPanelShell>
  60 +</template>
  61 +
  62 +<script>
  63 +import ConfigPanelShell from './config-panel-shell.vue'
  64 +
  65 +const MODULES = [
  66 + { key: 'payment', label: '微信支付', desc: '会员商城收款参数', icon: 'el-icon-wallet', iconClass: 'module-icon--payment' }
  67 +]
  68 +
  69 +export default {
  70 + name: 'MallPayConfigPanel',
  71 + components: { ConfigPanelShell },
  72 + props: {
  73 + form: { type: Object, required: true },
  74 + loading: { type: Boolean, default: false },
  75 + saving: { type: Boolean, default: false }
  76 + },
  77 + data() {
  78 + return { modules: MODULES }
  79 + }
  80 +}
  81 +</script>
  82 +
  83 +<style lang="scss" scoped>
  84 +.config-section {
  85 + border: 1px solid #ebeef5;
  86 +
  87 + &__header {
  88 + display: flex;
  89 + align-items: center;
  90 + gap: 8px;
  91 + font-weight: 500;
  92 + }
  93 +
  94 + &__tag {
  95 + padding: 0 8px;
  96 + height: 22px;
  97 + line-height: 22px;
  98 + border-radius: 4px;
  99 + background: #f4f4f5;
  100 + color: #909399;
  101 + font-size: 12px;
  102 + font-weight: normal;
  103 + }
  104 +
  105 + &__tip {
  106 + margin: 0 0 12px;
  107 + color: #909399;
  108 + font-size: 13px;
  109 + line-height: 1.6;
  110 + }
  111 +}
  112 +
  113 +.field-suffix {
  114 + margin-left: 8px;
  115 + color: #909399;
  116 + font-size: 13px;
  117 +
  118 + &--text {
  119 + display: inline-block;
  120 + min-width: 120px;
  121 + }
  122 +}
  123 +
  124 +.module-icon--payment { color: #67C23A; }
  125 +
  126 +.ncc-content-dark .config-section {
  127 + border-color: rgba(255, 255, 255, 0.08);
  128 +
  129 + &__tag,
  130 + &__tip,
  131 + .field-suffix {
  132 + color: rgba(255, 255, 255, 0.55);
  133 + }
  134 +
  135 + &__tag {
  136 + background: rgba(255, 255, 255, 0.08);
  137 + }
  138 +}
  139 +</style>
antis-ncc-admin/src/views/system/sysConfig/security-config-panel.vue 0 → 100644
  1 +<template>
  2 + <ConfigPanelShell
  3 + :modules="modules"
  4 + :loading="loading"
  5 + :saving="saving"
  6 + storage-key="securityConfigActiveModule"
  7 + default-module="login"
  8 + nav-theme="warning"
  9 + alert-title="左侧选择安全模块,右侧仅展示当前配置;保存后全量生效。"
  10 + alert-type="warning"
  11 + @save="$emit('save')"
  12 + >
  13 + <template #login>
  14 + <el-card shadow="never" class="config-section">
  15 + <div slot="header" class="config-section__header">
  16 + <span>登录策略</span>
  17 + <span class="config-section__tag">全站</span>
  18 + </div>
  19 + <p class="config-section__tip">
  20 + 控制账号登录互踢、会话超时与登录成功后的提示信息。
  21 + </p>
  22 + <el-row :gutter="20">
  23 + <el-col :span="12">
  24 + <el-form-item label="单一登录">
  25 + <el-select v-model="form.singleLogin" placeholder="请选择" style="width: 100%">
  26 + <el-option label="后登录踢出先登录" value="1" />
  27 + </el-select>
  28 + </el-form-item>
  29 + </el-col>
  30 + <el-col :span="12">
  31 + <el-form-item label="超时登出">
  32 + <el-input-number v-model="form.tokenTimeout" :min="1" :precision="0" :step="1"
  33 + controls-position="right" />
  34 + <span class="field-suffix">分钟</span>
  35 + </el-form-item>
  36 + </el-col>
  37 + </el-row>
  38 + </el-card>
  39 +
  40 + <el-card shadow="never" class="config-section">
  41 + <div slot="header" class="config-section__header">
  42 + <span>上次登录提示</span>
  43 + <span class="config-section__tag">登录页</span>
  44 + </div>
  45 + <p class="config-section__tip">
  46 + 开启后,用户登录成功时将展示上一次登录的时间、地点与 IP 信息,便于发现异常登录。
  47 + </p>
  48 + <el-form-item label="上次登录">
  49 + <el-switch v-model="form.lastLoginTimeSwitch" :active-value="1" :inactive-value="0" />
  50 + <span class="field-suffix field-suffix--text">
  51 + {{ Number(form.lastLoginTimeSwitch) === 1 ? '已开启' : '已关闭' }}
  52 + </span>
  53 + </el-form-item>
  54 + <div v-if="Number(form.lastLoginTimeSwitch) === 1" class="login-preview">
  55 + <div class="login-preview__title">效果预览</div>
  56 + <div class="login-preview__card">
  57 + <div class="login-preview__card-title">上次登录信息提示</div>
  58 + <p>时间:2018-10-17 12:40</p>
  59 + <p>地点:上海市</p>
  60 + <p>IP:255.255.0.0</p>
  61 + </div>
  62 + </div>
  63 + </el-card>
  64 + </template>
  65 +
  66 + <template #display>
  67 + <el-card shadow="never" class="config-section">
  68 + <div slot="header" class="config-section__header">
  69 + <span>页面水印</span>
  70 + <span class="config-section__tag">管理后台 / 手机端</span>
  71 + </div>
  72 + <p class="config-section__tip">
  73 + 开启后管理后台与员工手机端均显示「部门、姓名、电话、时间」水印;调整后请点击顶部保存持久化。
  74 + </p>
  75 + <el-form-item label="页面水印">
  76 + <el-switch v-model="form.watermarkSwitch" :active-value="1" :inactive-value="0"
  77 + @change="$emit('watermark-switch-change')" />
  78 + <span class="field-suffix field-suffix--text">
  79 + {{ Number(form.watermarkSwitch) === 1 ? '已开启' : '已关闭' }}
  80 + </span>
  81 + </el-form-item>
  82 + <template v-if="Number(form.watermarkSwitch) === 1">
  83 + <el-form-item label="PC水印深浅">
  84 + <div class="opacity-row">
  85 + <el-slider v-model="form.watermarkOpacityPc" :min="1" :max="100" :step="1" show-input
  86 + input-size="small" class="opacity-slider" @change="$emit('watermark-opacity-change')" />
  87 + <span class="form-tip">仅影响管理后台,数值越大越深(默认 10)</span>
  88 + </div>
  89 + </el-form-item>
  90 + <el-form-item label="手机水印深浅">
  91 + <div class="opacity-row">
  92 + <el-slider v-model="form.watermarkOpacityApp" :min="1" :max="100" :step="1" show-input
  93 + input-size="small" class="opacity-slider" />
  94 + <span class="form-tip">仅影响员工手机端,数值越大越深(默认 14)</span>
  95 + </div>
  96 + </el-form-item>
  97 + </template>
  98 + </el-card>
  99 +
  100 + <el-card shadow="never" class="config-section">
  101 + <div slot="header" class="config-section__header">
  102 + <span>手机号脱敏</span>
  103 + <span class="config-section__tag">全站列表</span>
  104 + </div>
  105 + <p class="config-section__tip">
  106 + 关闭时发送日志等展示明文手机号;开启后全站列表、详情与发送日志按脱敏规则展示。
  107 + </p>
  108 + <el-form-item label="手机号脱敏">
  109 + <el-switch v-model="form.privacyMobileMask" :active-value="1" :inactive-value="0"
  110 + @change="$emit('privacy-mobile-mask-change')" />
  111 + <span class="field-suffix field-suffix--text">
  112 + {{ Number(form.privacyMobileMask) === 1 ? '已开启' : '已关闭' }}
  113 + </span>
  114 + </el-form-item>
  115 + </el-card>
  116 + </template>
  117 +
  118 + <template #access>
  119 + <el-card shadow="never" class="config-section">
  120 + <div slot="header" class="config-section__header">
  121 + <span>IP 白名单</span>
  122 + <span class="config-section__tag">访问控制</span>
  123 + </div>
  124 + <p class="config-section__tip">
  125 + 开启验证后,仅允许白名单内的 IP 访问系统;每行一个 IP,支持多行配置。
  126 + </p>
  127 + <el-form-item label="开启验证">
  128 + <el-switch v-model="form.whitelistSwitch" :active-value="1" :inactive-value="0" />
  129 + <span class="field-suffix field-suffix--text">
  130 + {{ Number(form.whitelistSwitch) === 1 ? '已开启' : '已关闭' }}
  131 + </span>
  132 + </el-form-item>
  133 + <el-form-item label="允许访问IP">
  134 + <el-input
  135 + type="textarea"
  136 + :autosize="{ minRows: 6, maxRows: 14 }"
  137 + v-model="form.whiteListIp"
  138 + placeholder="每行一个 IP,例如:&#10;192.168.1.100&#10;10.0.0.0/24"
  139 + />
  140 + </el-form-item>
  141 + </el-card>
  142 + </template>
  143 + </ConfigPanelShell>
  144 +</template>
  145 +
  146 +<script>
  147 +import ConfigPanelShell from './config-panel-shell.vue'
  148 +
  149 +const MODULES = [
  150 + { key: 'login', label: '登录与会话', desc: '互踢、超时与登录提示', icon: 'el-icon-user', iconClass: 'module-icon--login' },
  151 + { key: 'display', label: '水印与脱敏', desc: '页面水印与手机号隐私', icon: 'el-icon-view', iconClass: 'module-icon--display' },
  152 + { key: 'access', label: '访问控制', desc: 'IP 白名单限制', icon: 'el-icon-lock', iconClass: 'module-icon--access' }
  153 +]
  154 +
  155 +export default {
  156 + name: 'SecurityConfigPanel',
  157 + components: { ConfigPanelShell },
  158 + props: {
  159 + form: {
  160 + type: Object,
  161 + required: true
  162 + },
  163 + loading: {
  164 + type: Boolean,
  165 + default: false
  166 + },
  167 + saving: {
  168 + type: Boolean,
  169 + default: false
  170 + }
  171 + },
  172 + data() {
  173 + return {
  174 + modules: MODULES
  175 + }
  176 + }
  177 +}
  178 +</script>
  179 +
  180 +<style lang="scss" scoped>
  181 +.module-icon {
  182 + margin-right: 8px;
  183 + font-size: 16px;
  184 + flex-shrink: 0;
  185 +
  186 + &--login {
  187 + color: #409EFF;
  188 + }
  189 +
  190 + &--display {
  191 + color: #67C23A;
  192 + }
  193 +
  194 + &--access {
  195 + color: #E6A23C;
  196 + }
  197 +}
  198 +
  199 +.config-section {
  200 + margin-bottom: 16px;
  201 + border: 1px solid #ebeef5;
  202 +
  203 + &:last-child {
  204 + margin-bottom: 0;
  205 + }
  206 +
  207 + &__header {
  208 + display: flex;
  209 + align-items: center;
  210 + gap: 8px;
  211 + font-weight: 500;
  212 + }
  213 +
  214 + &__tag {
  215 + padding: 0 8px;
  216 + height: 22px;
  217 + line-height: 22px;
  218 + border-radius: 4px;
  219 + background: #f4f4f5;
  220 + color: #909399;
  221 + font-size: 12px;
  222 + font-weight: normal;
  223 + }
  224 +
  225 + &__tip {
  226 + margin: 0 0 12px;
  227 + color: #909399;
  228 + font-size: 13px;
  229 + line-height: 1.6;
  230 + }
  231 +}
  232 +
  233 +.field-suffix {
  234 + margin-left: 8px;
  235 + color: #909399;
  236 + font-size: 13px;
  237 +
  238 + &--text {
  239 + display: inline-block;
  240 + min-width: 48px;
  241 + }
  242 +}
  243 +
  244 +.form-tip {
  245 + color: #909399;
  246 + font-size: 13px;
  247 + line-height: 1.6;
  248 +}
  249 +
  250 +.opacity-row {
  251 + display: flex;
  252 + align-items: center;
  253 + flex-wrap: wrap;
  254 + gap: 12px;
  255 + width: 100%;
  256 +}
  257 +
  258 +.opacity-slider {
  259 + width: 360px;
  260 + max-width: 100%;
  261 +}
  262 +
  263 +.login-preview {
  264 + margin-top: 4px;
  265 + padding-left: 120px;
  266 +
  267 + &__title {
  268 + margin-bottom: 8px;
  269 + color: #909399;
  270 + font-size: 12px;
  271 + }
  272 +
  273 + &__card {
  274 + width: 300px;
  275 + max-width: 100%;
  276 + padding: 12px 16px;
  277 + border: 1px solid #ebeef5;
  278 + border-radius: 8px;
  279 + background: #fafafa;
  280 +
  281 + &-title {
  282 + margin-bottom: 8px;
  283 + color: #303133;
  284 + font-size: 14px;
  285 + font-weight: 500;
  286 + }
  287 +
  288 + p {
  289 + margin: 4px 0;
  290 + color: #606266;
  291 + font-size: 13px;
  292 + line-height: 1.6;
  293 + }
  294 + }
  295 +}
  296 +
  297 +.ncc-content-dark {
  298 + .config-section {
  299 + border-color: rgba(255, 255, 255, 0.08);
  300 +
  301 + &__tag {
  302 + background: rgba(255, 255, 255, 0.08);
  303 + color: rgba(255, 255, 255, 0.55);
  304 + }
  305 +
  306 + &__tip,
  307 + .form-tip,
  308 + .field-suffix {
  309 + color: rgba(255, 255, 255, 0.55);
  310 + }
  311 + }
  312 +
  313 + .login-preview__card {
  314 + border-color: rgba(255, 255, 255, 0.08);
  315 + background: rgba(255, 255, 255, 0.03);
  316 +
  317 + &-title {
  318 + color: rgba(255, 255, 255, 0.85);
  319 + }
  320 +
  321 + p {
  322 + color: rgba(255, 255, 255, 0.65);
  323 + }
  324 + }
  325 +}
  326 +</style>
antis-ncc-admin/src/views/system/sysConfig/sms-config-panel.vue
1 <template> 1 <template>
2 - <div class="sms-config-panel" v-loading="loading">  
3 - <!-- 短信基础配置 -->  
4 - <el-card shadow="never" class="sms-config-card">  
5 - <div slot="header" class="sms-config-card__header">  
6 - <span>短信基础配置</span>  
7 - <span class="sms-config-card__tag">阿里云短信</span>  
8 - </div>  
9 - <el-alert  
10 - :title="configTip"  
11 - type="info"  
12 - :closable="false"  
13 - show-icon  
14 - class="sms-config-alert"  
15 - />  
16 - <el-row :gutter="20">  
17 - <el-col :span="12">  
18 - <el-form-item label="AccessKeyId" label-width="120px">  
19 - <el-input v-model="configForm.accessKeyId" clearable placeholder="阿里云 AccessKeyId" />  
20 - </el-form-item>  
21 - </el-col>  
22 - <el-col :span="12">  
23 - <el-form-item label="AccessKeySecret" label-width="130px"> 2 + <div class="sms-config-panel">
  3 + <ConfigPanelShell
  4 + :modules="modules"
  5 + :loading="loading"
  6 + :show-save="false"
  7 + storage-key="smsConfigActiveModule"
  8 + alert-title="左侧切换「基础配置」与「模板管理」;基础配置单独保存,模板支持增删改与测试发送。"
  9 + alert-type="info"
  10 + default-module="credentials"
  11 + >
  12 + <template #credentials>
  13 + <el-card shadow="never" class="config-section">
  14 + <div slot="header" class="config-section__header">
  15 + <span>短信基础配置</span>
  16 + <span class="config-section__tag">阿里云短信</span>
  17 + </div>
  18 + <el-alert :title="configTip" type="info" :closable="false" show-icon class="config-alert" />
  19 + <el-row :gutter="20">
  20 + <el-col :span="12">
  21 + <el-form-item label="AccessKeyId" label-width="120px">
  22 + <el-input v-model="configForm.accessKeyId" clearable placeholder="阿里云 AccessKeyId" />
  23 + </el-form-item>
  24 + </el-col>
  25 + <el-col :span="12">
  26 + <el-form-item label="AccessKeySecret" label-width="130px">
  27 + <el-input
  28 + v-model="configForm.accessKeySecret"
  29 + show-password
  30 + clearable
  31 + autocomplete="new-password"
  32 + :placeholder="configForm.hasAccessKeySecret ? `已配置 ${configForm.accessKeySecretMasked},留空不修改` : '阿里云 AccessKeySecret'"
  33 + />
  34 + </el-form-item>
  35 + </el-col>
  36 + <el-col :span="12">
  37 + <el-form-item label="默认签名" label-width="120px">
  38 + <el-input v-model="configForm.signName" clearable placeholder="绿纤聚财" />
  39 + </el-form-item>
  40 + </el-col>
  41 + <el-col :span="12">
  42 + <el-form-item label="厂商" label-width="120px">
  43 + <el-select v-model="configForm.company" style="width: 100%">
  44 + <el-option label="阿里云" value="1" />
  45 + <el-option label="腾讯云(仅兼容历史)" value="2" />
  46 + </el-select>
  47 + </el-form-item>
  48 + </el-col>
  49 + <el-col :span="12">
  50 + <el-form-item label="Endpoint" label-width="120px">
  51 + <el-input v-model="configForm.endpoint" clearable placeholder="dysmsapi.aliyuncs.com" />
  52 + </el-form-item>
  53 + </el-col>
  54 + <el-col :span="12">
  55 + <el-form-item label="生日防重发" label-width="120px">
  56 + <el-switch v-model="configForm.preventDuplicate" :active-value="1" :inactive-value="0" />
  57 + <span class="form-tip">同会员同日同场景不重复发送</span>
  58 + </el-form-item>
  59 + </el-col>
  60 + </el-row>
  61 + <div class="panel-actions">
  62 + <el-button type="primary" size="small" :loading="configSaving" @click="saveConfig">保存短信配置</el-button>
  63 + </div>
  64 + </el-card>
  65 + </template>
  66 +
  67 + <template #templates>
  68 + <el-card shadow="never" class="config-section config-section--flush">
  69 + <div class="panel-actions panel-actions--between">
  70 + <div class="toolbar-left">
24 <el-input 71 <el-input
25 - v-model="configForm.accessKeySecret"  
26 - show-password 72 + v-model="templateQuery.name"
  73 + size="small"
27 clearable 74 clearable
28 - autocomplete="new-password"  
29 - :placeholder="configForm.hasAccessKeySecret ? `已配置 ${configForm.accessKeySecretMasked},留空不修改` : '阿里云 AccessKeySecret'" 75 + placeholder="名称"
  76 + style="width: 140px; margin-right: 8px"
  77 + @keyup.enter.native="searchTemplates"
30 /> 78 />
31 - </el-form-item>  
32 - </el-col>  
33 - <el-col :span="12">  
34 - <el-form-item label="默认签名" label-width="120px">  
35 - <el-input v-model="configForm.signName" clearable placeholder="绿纤聚财" />  
36 - </el-form-item>  
37 - </el-col>  
38 - <el-col :span="12">  
39 - <el-form-item label="厂商" label-width="120px">  
40 - <el-select v-model="configForm.company" style="width: 100%">  
41 - <el-option label="阿里云" value="1" />  
42 - <el-option label="腾讯云(仅兼容历史)" value="2" />  
43 - </el-select>  
44 - </el-form-item>  
45 - </el-col>  
46 - <el-col :span="12">  
47 - <el-form-item label="Endpoint" label-width="120px">  
48 - <el-input v-model="configForm.endpoint" clearable placeholder="dysmsapi.aliyuncs.com" />  
49 - </el-form-item>  
50 - </el-col>  
51 - <el-col :span="12">  
52 - <el-form-item label="生日防重发" label-width="120px">  
53 - <el-switch v-model="configForm.preventDuplicate" :active-value="1" :inactive-value="0" />  
54 - <span class="form-tip">同会员同日同场景不重复发送</span>  
55 - </el-form-item>  
56 - </el-col>  
57 - </el-row>  
58 - <div class="panel-actions">  
59 - <el-button type="primary" size="small" :loading="configSaving" @click="saveConfig">保存短信配置</el-button>  
60 - </div>  
61 - </el-card>  
62 -  
63 - <!-- 模板列表(主工作区) -->  
64 - <el-card shadow="never" class="sms-config-card sms-config-card--main">  
65 - <div slot="header" class="sms-config-card__header">  
66 - <span>短信模板</span>  
67 - <span class="sms-config-card__tag">主工作区</span>  
68 - </div>  
69 -  
70 - <div class="panel-actions panel-actions--between">  
71 - <div class="toolbar-left">  
72 - <el-input  
73 - v-model="templateQuery.name"  
74 - size="small"  
75 - clearable  
76 - placeholder="名称"  
77 - style="width: 140px; margin-right: 8px"  
78 - @keyup.enter.native="searchTemplates"  
79 - />  
80 - <el-input  
81 - v-model="templateQuery.sceneCode"  
82 - size="small"  
83 - clearable  
84 - placeholder="场景编码"  
85 - style="width: 140px; margin-right: 8px"  
86 - @keyup.enter.native="searchTemplates"  
87 - />  
88 - <el-button size="small" icon="el-icon-search" @click="searchTemplates">查询</el-button>  
89 - </div>  
90 - <div class="toolbar-right">  
91 - <el-button size="small" icon="el-icon-message" @click="openTestDialog">测试发送</el-button>  
92 - <el-button size="small" icon="el-icon-document" @click="openLogDrawer">发送日志</el-button>  
93 - <el-button type="primary" size="small" icon="el-icon-plus" @click="openTemplateDialog()">新增模板</el-button> 79 + <el-input
  80 + v-model="templateQuery.sceneCode"
  81 + size="small"
  82 + clearable
  83 + placeholder="场景编码"
  84 + style="width: 140px; margin-right: 8px"
  85 + @keyup.enter.native="searchTemplates"
  86 + />
  87 + <el-button size="small" icon="el-icon-search" @click="searchTemplates">查询</el-button>
  88 + </div>
  89 + <div class="toolbar-right">
  90 + <el-button size="small" icon="el-icon-message" @click="openTestDialog">测试发送</el-button>
  91 + <el-button size="small" icon="el-icon-document" @click="openLogDrawer">发送日志</el-button>
  92 + <el-button type="primary" size="small" icon="el-icon-plus" @click="openTemplateDialog()">新增模板</el-button>
  93 + </div>
94 </div> 94 </div>
95 - </div>  
96 95
97 - <NCC-table v-loading="templateLoading" :data="templateList">  
98 - <el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip>  
99 - <template slot-scope="{ row }">  
100 - <div class="table-cell-with-icon">  
101 - <i class="el-icon-s-order cell-icon cell-icon--primary" />  
102 - <span>{{ row.name || '无' }}</span>  
103 - </div>  
104 - </template>  
105 - </el-table-column>  
106 - <el-table-column prop="templateCode" label="CODE" min-width="140" show-overflow-tooltip>  
107 - <template slot-scope="{ row }">{{ row.templateCode || '无' }}</template>  
108 - </el-table-column>  
109 - <el-table-column prop="signName" label="签名" min-width="110" show-overflow-tooltip>  
110 - <template slot-scope="{ row }">{{ row.signName || '无' }}</template>  
111 - </el-table-column>  
112 - <el-table-column label="类型" width="100">  
113 - <template slot-scope="{ row }">  
114 - <el-tag size="mini" :type="templateTypeTag(row.templateType)" disable-transitions>  
115 - {{ row.templateTypeName || templateTypeName(row.templateType) }}  
116 - </el-tag>  
117 - </template>  
118 - </el-table-column>  
119 - <el-table-column prop="sceneCode" label="场景" min-width="110" show-overflow-tooltip>  
120 - <template slot-scope="{ row }">{{ row.sceneCode || '无' }}</template>  
121 - </el-table-column>  
122 - <el-table-column label="启用" width="90" align="center">  
123 - <template slot-scope="{ row }">  
124 - <el-tag size="mini" :type="row.enabled === 1 ? 'success' : 'info'" disable-transitions>  
125 - {{ row.enabled === 1 ? '启用' : '停用' }}  
126 - </el-tag>  
127 - </template>  
128 - </el-table-column>  
129 - <el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip>  
130 - <template slot-scope="{ row }">{{ row.remark || '无' }}</template>  
131 - </el-table-column>  
132 - <el-table-column label="操作" width="220" fixed="right">  
133 - <template slot-scope="{ row }">  
134 - <el-button type="text" size="small" @click="openTemplateDialog(row)">编辑</el-button>  
135 - <el-button type="text" size="small" @click="toggleEnabled(row)">  
136 - {{ row.enabled === 1 ? '停用' : '启用' }}  
137 - </el-button>  
138 - <el-button type="text" size="small" class="NCC-table-delBtn" @click="removeTemplate(row)">删除</el-button>  
139 - </template>  
140 - </el-table-column>  
141 - </NCC-table>  
142 - <pagination  
143 - :total="templateTotal"  
144 - :page.sync="templateQuery.currentPage"  
145 - :limit.sync="templateQuery.pageSize"  
146 - @pagination="loadTemplates"  
147 - />  
148 - </el-card> 96 + <NCC-table v-loading="templateLoading" :data="templateList">
  97 + <el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip>
  98 + <template slot-scope="{ row }">
  99 + <div class="table-cell-with-icon">
  100 + <i class="el-icon-s-order cell-icon cell-icon--primary" />
  101 + <span>{{ row.name || '无' }}</span>
  102 + </div>
  103 + </template>
  104 + </el-table-column>
  105 + <el-table-column prop="templateCode" label="CODE" min-width="140" show-overflow-tooltip>
  106 + <template slot-scope="{ row }">{{ row.templateCode || '无' }}</template>
  107 + </el-table-column>
  108 + <el-table-column prop="signName" label="签名" min-width="110" show-overflow-tooltip>
  109 + <template slot-scope="{ row }">{{ row.signName || '无' }}</template>
  110 + </el-table-column>
  111 + <el-table-column label="类型" width="100">
  112 + <template slot-scope="{ row }">
  113 + <el-tag size="mini" :type="templateTypeTag(row.templateType)" disable-transitions>
  114 + {{ row.templateTypeName || templateTypeName(row.templateType) }}
  115 + </el-tag>
  116 + </template>
  117 + </el-table-column>
  118 + <el-table-column prop="sceneCode" label="场景" min-width="110" show-overflow-tooltip>
  119 + <template slot-scope="{ row }">{{ row.sceneCode || '无' }}</template>
  120 + </el-table-column>
  121 + <el-table-column label="启用" width="90" align="center">
  122 + <template slot-scope="{ row }">
  123 + <el-tag size="mini" :type="row.enabled === 1 ? 'success' : 'info'" disable-transitions>
  124 + {{ row.enabled === 1 ? '启用' : '停用' }}
  125 + </el-tag>
  126 + </template>
  127 + </el-table-column>
  128 + <el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip>
  129 + <template slot-scope="{ row }">{{ row.remark || '无' }}</template>
  130 + </el-table-column>
  131 + <el-table-column label="操作" width="220" fixed="right">
  132 + <template slot-scope="{ row }">
  133 + <el-button type="text" size="small" @click="openTemplateDialog(row)">编辑</el-button>
  134 + <el-button type="text" size="small" @click="toggleEnabled(row)">
  135 + {{ row.enabled === 1 ? '停用' : '启用' }}
  136 + </el-button>
  137 + <el-button type="text" size="small" class="NCC-table-delBtn" @click="removeTemplate(row)">删除</el-button>
  138 + </template>
  139 + </el-table-column>
  140 + </NCC-table>
  141 + <pagination
  142 + :total="templateTotal"
  143 + :page.sync="templateQuery.currentPage"
  144 + :limit.sync="templateQuery.pageSize"
  145 + @pagination="loadTemplates"
  146 + />
  147 + </el-card>
  148 + </template>
  149 + </ConfigPanelShell>
149 150
150 <sms-template-dialog ref="templateDialog" @success="loadTemplates" /> 151 <sms-template-dialog ref="templateDialog" @success="loadTemplates" />
151 <sms-test-send-dialog ref="testDialog" /> 152 <sms-test-send-dialog ref="testDialog" />
@@ -154,6 +155,7 @@ @@ -154,6 +155,7 @@
154 </template> 155 </template>
155 156
156 <script> 157 <script>
  158 +import ConfigPanelShell from './config-panel-shell.vue'
157 import { 159 import {
158 getSmsConfig, 160 getSmsConfig,
159 saveSmsConfig, 161 saveSmsConfig,
@@ -165,15 +167,22 @@ import SmsTemplateDialog from &#39;./sms-template-dialog.vue&#39; @@ -165,15 +167,22 @@ import SmsTemplateDialog from &#39;./sms-template-dialog.vue&#39;
165 import SmsTestSendDialog from './sms-test-send-dialog.vue' 167 import SmsTestSendDialog from './sms-test-send-dialog.vue'
166 import SmsSendLogDrawer from './sms-send-log-drawer.vue' 168 import SmsSendLogDrawer from './sms-send-log-drawer.vue'
167 169
  170 +const MODULES = [
  171 + { key: 'credentials', label: '基础配置', desc: 'AccessKey 与签名', icon: 'el-icon-key', iconClass: 'module-icon--credentials' },
  172 + { key: 'templates', label: '模板管理', desc: '场景模板与发送', icon: 'el-icon-message', iconClass: 'module-icon--templates' }
  173 +]
  174 +
168 export default { 175 export default {
169 name: 'SmsConfigPanel', 176 name: 'SmsConfigPanel',
170 components: { 177 components: {
  178 + ConfigPanelShell,
171 SmsTemplateDialog, 179 SmsTemplateDialog,
172 SmsTestSendDialog, 180 SmsTestSendDialog,
173 SmsSendLogDrawer 181 SmsSendLogDrawer
174 }, 182 },
175 data() { 183 data() {
176 return { 184 return {
  185 + modules: MODULES,
177 loading: false, 186 loading: false,
178 configSaving: false, 187 configSaving: false,
179 templateLoading: false, 188 templateLoading: false,
@@ -310,73 +319,73 @@ export default { @@ -310,73 +319,73 @@ export default {
310 </script> 319 </script>
311 320
312 <style lang="scss" scoped> 321 <style lang="scss" scoped>
313 -.sms-config-panel {  
314 - .sms-config-card {  
315 - margin-bottom: 16px;  
316 - border: 1px solid #ebeef5; 322 +.config-section {
  323 + border: 1px solid #ebeef5;
317 324
318 - &--main {  
319 - margin-bottom: 0;  
320 - } 325 + &--flush {
  326 + border: none;
  327 + box-shadow: none;
321 328
322 - &__header {  
323 - display: flex;  
324 - align-items: center;  
325 - gap: 8px;  
326 - font-weight: 500;  
327 - }  
328 -  
329 - &__tag {  
330 - padding: 0 8px;  
331 - height: 22px;  
332 - line-height: 22px;  
333 - border-radius: 4px;  
334 - background: #f4f4f5;  
335 - color: #909399;  
336 - font-size: 12px;  
337 - font-weight: normal; 329 + ::v-deep .el-card__body {
  330 + padding: 0;
338 } 331 }
339 } 332 }
340 333
341 - .sms-config-alert {  
342 - margin-bottom: 12px;  
343 - }  
344 -  
345 - .panel-actions {  
346 - margin: 4px 0 0;  
347 - }  
348 -  
349 - .panel-actions--between { 334 + &__header {
350 display: flex; 335 display: flex;
351 - justify-content: space-between;  
352 align-items: center; 336 align-items: center;
353 - flex-wrap: wrap;  
354 gap: 8px; 337 gap: 8px;
355 - margin-bottom: 12px; 338 + font-weight: 500;
356 } 339 }
357 340
358 - .toolbar-left,  
359 - .toolbar-right {  
360 - display: flex;  
361 - flex-wrap: wrap;  
362 - align-items: center;  
363 - }  
364 -  
365 - .toolbar-right {  
366 - justify-content: flex-start;  
367 - }  
368 -  
369 - .form-tip {  
370 - margin-left: 12px; 341 + &__tag {
  342 + padding: 0 8px;
  343 + height: 22px;
  344 + line-height: 22px;
  345 + border-radius: 4px;
  346 + background: #f4f4f5;
371 color: #909399; 347 color: #909399;
372 font-size: 12px; 348 font-size: 12px;
373 - line-height: 1.5; 349 + font-weight: normal;
374 } 350 }
375 } 351 }
376 352
377 -/* 暗黑兼容 */  
378 -.ncc-content-dark .sms-config-panel {  
379 - .sms-config-card { 353 +.module-icon--credentials { color: #409EFF; }
  354 +.module-icon--templates { color: #67C23A; }
  355 +
  356 +.config-alert {
  357 + margin-bottom: 12px;
  358 +}
  359 +
  360 +.panel-actions {
  361 + margin: 4px 0 0;
  362 +}
  363 +
  364 +.panel-actions--between {
  365 + display: flex;
  366 + justify-content: space-between;
  367 + align-items: center;
  368 + flex-wrap: wrap;
  369 + gap: 8px;
  370 + margin-bottom: 12px;
  371 +}
  372 +
  373 +.toolbar-left,
  374 +.toolbar-right {
  375 + display: flex;
  376 + flex-wrap: wrap;
  377 + align-items: center;
  378 +}
  379 +
  380 +.form-tip {
  381 + margin-left: 12px;
  382 + color: #909399;
  383 + font-size: 12px;
  384 + line-height: 1.5;
  385 +}
  386 +
  387 +.ncc-content-dark {
  388 + .config-section {
380 border-color: rgba(255, 255, 255, 0.08); 389 border-color: rgba(255, 255, 255, 0.08);
381 390
382 &__tag { 391 &__tag {
antis-ncc-admin/src/views/system/sysConfig/sms-send-log-drawer.vue
@@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
2 <el-drawer 2 <el-drawer
3 title="发送日志" 3 title="发送日志"
4 :visible.sync="visible" 4 :visible.sync="visible"
5 - size="720px" 5 + size="75%"
6 append-to-body 6 append-to-body
7 :wrapper-closable="false" 7 :wrapper-closable="false"
8 custom-class="sms-send-log-drawer" 8 custom-class="sms-send-log-drawer"
@@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
15 size="small" 15 size="small"
16 clearable 16 clearable
17 placeholder="场景" 17 placeholder="场景"
18 - style="width: 140px; margin-right: 8px" 18 + class="toolbar-item toolbar-item--scene"
19 > 19 >
20 <el-option label="Birthday" value="Birthday" /> 20 <el-option label="Birthday" value="Birthday" />
21 <el-option label="Login" value="Login" /> 21 <el-option label="Login" value="Login" />
@@ -27,7 +27,7 @@ @@ -27,7 +27,7 @@
27 size="small" 27 size="small"
28 clearable 28 clearable
29 placeholder="状态" 29 placeholder="状态"
30 - style="width: 120px; margin-right: 8px" 30 + class="toolbar-item toolbar-item--status"
31 > 31 >
32 <el-option label="成功" :value="1" /> 32 <el-option label="成功" :value="1" />
33 <el-option label="失败" :value="0" /> 33 <el-option label="失败" :value="0" />
@@ -41,38 +41,41 @@ @@ -41,38 +41,41 @@
41 range-separator="至" 41 range-separator="至"
42 start-placeholder="业务日起" 42 start-placeholder="业务日起"
43 end-placeholder="业务日止" 43 end-placeholder="业务日止"
44 - style="margin-right: 8px; width: 260px" 44 + class="toolbar-item toolbar-item--date"
45 /> 45 />
46 <el-button size="small" icon="el-icon-search" @click="search">查询</el-button> 46 <el-button size="small" icon="el-icon-search" @click="search">查询</el-button>
47 <el-button size="small" icon="el-icon-refresh" @click="reset">重置</el-button> 47 <el-button size="small" icon="el-icon-refresh" @click="reset">重置</el-button>
48 </div> 48 </div>
49 49
50 - <NCC-table v-loading="loading" :data="list">  
51 - <el-table-column label="时间" width="170"> 50 + <NCC-table v-loading="loading" :data="list" class="sms-send-log-table">
  51 + <el-table-column label="时间" width="165">
52 <template slot-scope="{ row }"> 52 <template slot-scope="{ row }">
53 {{ row.creatorTime ? ncc.dateFormat(row.creatorTime, 'YYYY-MM-DD HH:mm:ss') : '无' }} 53 {{ row.creatorTime ? ncc.dateFormat(row.creatorTime, 'YYYY-MM-DD HH:mm:ss') : '无' }}
54 </template> 54 </template>
55 </el-table-column> 55 </el-table-column>
56 - <el-table-column prop="sceneCode" label="场景" width="110" show-overflow-tooltip> 56 + <el-table-column prop="sceneCode" label="场景" width="100" show-overflow-tooltip>
57 <template slot-scope="{ row }">{{ row.sceneCode || '无' }}</template> 57 <template slot-scope="{ row }">{{ row.sceneCode || '无' }}</template>
58 </el-table-column> 58 </el-table-column>
59 - <el-table-column prop="templateCode" label="CODE" min-width="130" show-overflow-tooltip>  
60 - <template slot-scope="{ row }">{{ row.templateCode || '无' }}</template>  
61 - </el-table-column>  
62 - <el-table-column prop="mobileMasked" label="手机号" width="130" show-overflow-tooltip> 59 + <el-table-column prop="mobileMasked" label="手机号" width="120" show-overflow-tooltip>
63 <template slot-scope="{ row }">{{ $maskMobile(row.mobileMasked) || '无' }}</template> 60 <template slot-scope="{ row }">{{ $maskMobile(row.mobileMasked) || '无' }}</template>
64 </el-table-column> 61 </el-table-column>
65 - <el-table-column prop="memberName" label="会员" width="100" show-overflow-tooltip> 62 + <el-table-column prop="memberName" label="会员" width="90" show-overflow-tooltip>
66 <template slot-scope="{ row }">{{ row.memberName || '无' }}</template> 63 <template slot-scope="{ row }">{{ row.memberName || '无' }}</template>
67 </el-table-column> 64 </el-table-column>
68 - <el-table-column label="测试" width="70" align="center"> 65 + <el-table-column prop="sendContent" label="内容" min-width="280" show-overflow-tooltip>
69 <template slot-scope="{ row }"> 66 <template slot-scope="{ row }">
70 - <el-tag size="mini" :type="row.isTest === 1 ? 'warning' : 'info'" disable-transitions>  
71 - {{ row.isTest === 1 ? '是' : '否' }}  
72 - </el-tag> 67 + {{ row.sendContent || formatSendContent(row) || '无' }}
  68 + </template>
  69 + </el-table-column>
  70 + <el-table-column prop="creatorUserName" label="发送人" width="100" show-overflow-tooltip>
  71 + <template slot-scope="{ row }">
  72 + <div class="table-cell-with-icon">
  73 + <i class="el-icon-user cell-icon cell-icon--primary" />
  74 + <span>{{ row.creatorUserName || '无' }}</span>
  75 + </div>
73 </template> 76 </template>
74 </el-table-column> 77 </el-table-column>
75 - <el-table-column label="状态" width="90" align="center"> 78 + <el-table-column label="状态" width="100" align="center">
76 <template slot-scope="{ row }"> 79 <template slot-scope="{ row }">
77 <el-tag 80 <el-tag
78 size="mini" 81 size="mini"
@@ -81,6 +84,7 @@ @@ -81,6 +84,7 @@
81 > 84 >
82 {{ row.statusName || statusName(row.status) }} 85 {{ row.statusName || statusName(row.status) }}
83 </el-tag> 86 </el-tag>
  87 + <el-tag v-if="row.isTest === 1" size="mini" type="warning" disable-transitions>测试</el-tag>
84 </template> 88 </template>
85 </el-table-column> 89 </el-table-column>
86 <el-table-column prop="providerResult" label="结果" min-width="140" show-overflow-tooltip> 90 <el-table-column prop="providerResult" label="结果" min-width="140" show-overflow-tooltip>
@@ -177,6 +181,14 @@ export default { @@ -177,6 +181,14 @@ export default {
177 statusName(status) { 181 statusName(status) {
178 const map = { 0: '失败', 1: '成功', 2: '跳过' } 182 const map = { 0: '失败', 1: '成功', 2: '跳过' }
179 return map[status] || '无' 183 return map[status] || '无'
  184 + },
  185 + formatSendContent(row) {
  186 + if (!row) return ''
  187 + const parts = []
  188 + if (row.signName) parts.push(`【${row.signName}】`)
  189 + if (row.templateCode) parts.push(`(${row.templateCode})`)
  190 + if (row.templateParam) parts.push(row.templateParam)
  191 + return parts.join(' ')
180 } 192 }
181 } 193 }
182 } 194 }
@@ -184,25 +196,129 @@ export default { @@ -184,25 +196,129 @@ export default {
184 196
185 <style lang="scss" scoped> 197 <style lang="scss" scoped>
186 .sms-send-log-body { 198 .sms-send-log-body {
  199 + display: flex;
  200 + flex-direction: column;
187 padding: 0 16px 16px; 201 padding: 0 16px 16px;
188 height: 100%; 202 height: 100%;
189 box-sizing: border-box; 203 box-sizing: border-box;
190 - overflow: auto; 204 + overflow: hidden;
191 } 205 }
192 206
193 .sms-send-log-toolbar { 207 .sms-send-log-toolbar {
194 display: flex; 208 display: flex;
195 flex-wrap: wrap; 209 flex-wrap: wrap;
196 align-items: center; 210 align-items: center;
  211 + gap: 8px;
197 margin-bottom: 12px; 212 margin-bottom: 12px;
  213 + flex-shrink: 0;
  214 +}
  215 +
  216 +.toolbar-item {
  217 + &--scene {
  218 + width: 120px;
  219 + }
  220 +
  221 + &--status {
  222 + width: 100px;
  223 + }
  224 +
  225 + &--date {
  226 + width: 260px;
  227 + max-width: 100%;
  228 + }
198 } 229 }
  230 +
199 </style> 231 </style>
200 232
201 <style lang="scss"> 233 <style lang="scss">
  234 +/*
  235 + * 发送日志抽屉 append-to-body,表格不在 #app 内,全局 #app .el-table 41px 不生效。
  236 + * 与 common.scss / NCC-table 行高规范对齐。
  237 + */
202 .sms-send-log-drawer { 238 .sms-send-log-drawer {
203 .el-drawer__body { 239 .el-drawer__body {
204 padding: 0; 240 padding: 0;
205 overflow: hidden; 241 overflow: hidden;
206 } 242 }
  243 +
  244 + .sms-send-log-table {
  245 + flex: 1;
  246 + min-height: 0;
  247 +
  248 + .el-table {
  249 + width: 100% !important;
  250 + }
  251 +
  252 + .el-table__body-wrapper,
  253 + .el-table__header-wrapper {
  254 + overflow-x: hidden !important;
  255 + }
  256 + }
  257 +
  258 + .el-table:not(.columnTable) {
  259 + th.el-table__cell,
  260 + td.el-table__cell,
  261 + .el-table__header-wrapper th,
  262 + .el-table__body-wrapper td,
  263 + .el-table__fixed-header-wrapper th,
  264 + .el-table__fixed-body-wrapper td {
  265 + padding: 0 !important;
  266 + height: 41px !important;
  267 + line-height: normal !important;
  268 + box-sizing: border-box;
  269 + vertical-align: middle !important;
  270 + }
  271 +
  272 + .cell {
  273 + box-sizing: border-box;
  274 + display: flex;
  275 + align-items: center;
  276 + height: 41px !important;
  277 + line-height: normal !important;
  278 + white-space: nowrap !important;
  279 + overflow: hidden;
  280 + text-overflow: ellipsis;
  281 + min-width: 0;
  282 + }
  283 +
  284 + .el-table__header .cell {
  285 + display: flex;
  286 + align-items: center;
  287 + height: 41px !important;
  288 + line-height: normal !important;
  289 + }
  290 +
  291 + td.is-center > .cell,
  292 + th.is-center > .cell {
  293 + justify-content: center;
  294 + }
  295 +
  296 + td.is-right > .cell,
  297 + th.is-right > .cell {
  298 + justify-content: flex-end;
  299 + }
  300 +
  301 + td.is-left > .cell,
  302 + th.is-left > .cell {
  303 + justify-content: flex-start;
  304 + }
  305 +
  306 + .cell .el-tag {
  307 + height: 20px;
  308 + line-height: 18px;
  309 + padding: 0 5px;
  310 + font-size: 12px;
  311 + margin: 0;
  312 + flex-shrink: 0;
  313 + }
  314 +
  315 + .cell .el-tag + .el-tag {
  316 + margin-left: 4px;
  317 + }
  318 +
  319 + .cell .table-cell-with-icon {
  320 + height: 41px;
  321 + }
  322 + }
207 } 323 }
208 </style> 324 </style>
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqSms/LqSmsSendLogListOutput.cs
@@ -15,11 +15,20 @@ namespace NCC.Extend.Entitys.Dto.LqSms @@ -15,11 +15,20 @@ namespace NCC.Extend.Entitys.Dto.LqSms
15 public string memberId { get; set; } 15 public string memberId { get; set; }
16 public string memberName { get; set; } 16 public string memberName { get; set; }
17 public string templateParam { get; set; } 17 public string templateParam { get; set; }
  18 + /// <summary>
  19 + /// 发送内容摘要
  20 + /// </summary>
  21 + public string sendContent { get; set; }
18 public int status { get; set; } 22 public int status { get; set; }
19 public string statusName { get; set; } 23 public string statusName { get; set; }
20 public string providerResult { get; set; } 24 public string providerResult { get; set; }
21 public DateTime? bizDate { get; set; } 25 public DateTime? bizDate { get; set; }
22 public int isTest { get; set; } 26 public int isTest { get; set; }
23 public DateTime? creatorTime { get; set; } 27 public DateTime? creatorTime { get; set; }
  28 + public string creatorUserId { get; set; }
  29 + /// <summary>
  30 + /// 发送人姓名(BASE_USER.F_RealName,无则显示账号或系统)
  31 + /// </summary>
  32 + public string creatorUserName { get; set; }
24 } 33 }
25 } 34 }
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Entity/lq_sms_send_log/LqSmsSendLogEntity.cs
@@ -73,6 +73,12 @@ namespace NCC.Extend.Entitys.lq_sms_send_log @@ -73,6 +73,12 @@ namespace NCC.Extend.Entitys.lq_sms_send_log
73 public string TemplateParam { get; set; } 73 public string TemplateParam { get; set; }
74 74
75 /// <summary> 75 /// <summary>
  76 + /// 发送内容摘要(签名 + 模板 + 参数,便于日志查看)
  77 + /// </summary>
  78 + [SugarColumn(ColumnName = "F_SendContent", IsNullable = true)]
  79 + public string SendContent { get; set; }
  80 +
  81 + /// <summary>
76 /// 发送状态(见 <see cref="SmsSendStatusEnum"/>) 82 /// 发送状态(见 <see cref="SmsSendStatusEnum"/>)
77 /// </summary> 83 /// </summary>
78 [SugarColumn(ColumnName = "F_Status")] 84 [SugarColumn(ColumnName = "F_Status")]
netcore/src/Modularity/Extend/NCC.Extend/Helpers/BillingCustomerTypeHelper.cs 0 → 100644
  1 +using System;
  2 +using System.Threading.Tasks;
  3 +using NCC.Code;
  4 +using NCC.Extend.Entitys.Enum;
  5 +using NCC.Extend.Entitys.lq_khxx;
  6 +using NCC.Extend.Entitys.lq_kd_kdjlb;
  7 +using SqlSugar;
  8 +
  9 +namespace NCC.Extend.Helpers
  10 +{
  11 + /// <summary>
  12 + /// 开单顾客类型(gjlx)解析与补全:开单时应快照会员 khlx,历史/漏写字段可从会员档案兜底。
  13 + /// </summary>
  14 + public static class BillingCustomerTypeHelper
  15 + {
  16 + /// <summary>
  17 + /// 取有效顾客类型编码:优先开单 gjlx,为空时用会员 khlx。
  18 + /// </summary>
  19 + public static string ResolveEffectiveGjlx(string gjlx, string memberKhlx)
  20 + {
  21 + if (!string.IsNullOrWhiteSpace(gjlx))
  22 + return gjlx.Trim();
  23 + if (!string.IsNullOrWhiteSpace(memberKhlx))
  24 + return memberKhlx.Trim();
  25 + return null;
  26 + }
  27 +
  28 + /// <summary>
  29 + /// 解析为顾客类型展示名(线索/新客/散客/会员/归档);均无则返回「无」。
  30 + /// </summary>
  31 + public static string ResolveCustomerTypeName(string gjlx, string memberKhlx = null)
  32 + {
  33 + var effective = ResolveEffectiveGjlx(gjlx, memberKhlx);
  34 + if (string.IsNullOrEmpty(effective))
  35 + return "无";
  36 +
  37 + if (int.TryParse(effective, out var typeValue))
  38 + {
  39 + var desc = EnumHelper.GetEnumDesc<MemberTypeEnum>(typeValue);
  40 + return string.IsNullOrEmpty(desc) ? effective : desc;
  41 + }
  42 +
  43 + return effective;
  44 + }
  45 +
  46 + /// <summary>
  47 + /// 新建/更新开单前:gjlx 为空时从会员档案 khlx 写入快照。
  48 + /// </summary>
  49 + public static async Task EnsureGjlxFromMemberAsync(ISqlSugarClient db, LqKdKdjlbEntity entity)
  50 + {
  51 + if (entity == null || !string.IsNullOrWhiteSpace(entity.Gjlx) || string.IsNullOrWhiteSpace(entity.Kdhy))
  52 + return;
  53 +
  54 + var khlx = await db.Queryable<LqKhxxEntity>()
  55 + .Where(x => x.Id == entity.Kdhy)
  56 + .Select(x => x.Khlx)
  57 + .FirstAsync();
  58 + if (!string.IsNullOrWhiteSpace(khlx))
  59 + entity.Gjlx = khlx.Trim();
  60 + }
  61 + }
  62 +}
netcore/src/Modularity/Extend/NCC.Extend/Helpers/ConsumeEditableDaysHelper.cs
@@ -23,6 +23,11 @@ namespace NCC.Extend.Helpers @@ -23,6 +23,11 @@ namespace NCC.Extend.Helpers
23 public const string KeyConsumeEditableDays = "consumeEditableDays"; 23 public const string KeyConsumeEditableDays = "consumeEditableDays";
24 24
25 /// <summary> 25 /// <summary>
  26 + /// 配置键:小程序端是否允许跨月修改/作废/重开单(1=允许,0=不允许)
  27 + /// </summary>
  28 + public const string KeyConsumeAllowCrossMonthEdit = "consumeAllowCrossMonthEdit";
  29 +
  30 + /// <summary>
26 /// 配置键:管理后台作废耗卡允许天数 31 /// 配置键:管理后台作废耗卡允许天数
27 /// </summary> 32 /// </summary>
28 public const string KeyAdminConsumeVoidEditableDays = "adminConsumeVoidEditableDays"; 33 public const string KeyAdminConsumeVoidEditableDays = "adminConsumeVoidEditableDays";
@@ -63,6 +68,11 @@ namespace NCC.Extend.Helpers @@ -63,6 +68,11 @@ namespace NCC.Extend.Helpers
63 public const int DefaultDays = 3; 68 public const int DefaultDays = 3;
64 69
65 /// <summary> 70 /// <summary>
  71 + /// 小程序端缺省:不允许跨月修改
  72 + /// </summary>
  73 + public const int DefaultConsumeAllowCrossMonthEdit = 0;
  74 +
  75 + /// <summary>
66 /// 管理后台缺省天数:0 表示不限制 76 /// 管理后台缺省天数:0 表示不限制
67 /// </summary> 77 /// </summary>
68 public const int DefaultAdminOperationEditableDays = 0; 78 public const int DefaultAdminOperationEditableDays = 0;
@@ -81,12 +91,23 @@ namespace NCC.Extend.Helpers @@ -81,12 +91,23 @@ namespace NCC.Extend.Helpers
81 } 91 }
82 92
83 /// <summary> 93 /// <summary>
  94 + /// 小程序端是否允许跨月修改/作废/重开单
  95 + /// </summary>
  96 + public static async Task<bool> GetConsumeAllowCrossMonthEditAsync(ISqlSugarClient db)
  97 + {
  98 + return await GetBoolConfigAsync(db, KeyConsumeAllowCrossMonthEdit, DefaultConsumeAllowCrossMonthEdit);
  99 + }
  100 +
  101 + /// <summary>
84 /// 校验小程序端耗卡是否在允许修改/作废的时间窗口内 102 /// 校验小程序端耗卡是否在允许修改/作废的时间窗口内
85 /// </summary> 103 /// </summary>
86 public static async Task EnsureConsumeWithinEditableWindowAsync(ISqlSugarClient db, LqXhHyhkEntity card) 104 public static async Task EnsureConsumeWithinEditableWindowAsync(ISqlSugarClient db, LqXhHyhkEntity card)
87 { 105 {
88 var days = await GetConsumeEditableDaysAsync(db); 106 var days = await GetConsumeEditableDaysAsync(db);
89 EnsureWithinEditableWindow(card, days, "耗卡发生已超过 {0} 天,不允许修改/作废"); 107 EnsureWithinEditableWindow(card, days, "耗卡发生已超过 {0} 天,不允许修改/作废");
  108 + var allowCrossMonth = await GetConsumeAllowCrossMonthEditAsync(db);
  109 + if (!allowCrossMonth)
  110 + EnsureSameCalendarMonth(card, "耗卡已跨月,不允许修改/作废");
90 } 111 }
91 112
92 /// <summary> 113 /// <summary>
@@ -178,6 +199,16 @@ namespace NCC.Extend.Helpers @@ -178,6 +199,16 @@ namespace NCC.Extend.Helpers
178 throw NCCException.Oh(string.Format(messageTemplate, days)); 199 throw NCCException.Oh(string.Format(messageTemplate, days));
179 } 200 }
180 201
  202 + private static void EnsureSameCalendarMonth(LqXhHyhkEntity card, string message)
  203 + {
  204 + var baseTime = card.Hksj ?? card.CreateTime;
  205 + if (!baseTime.HasValue)
  206 + return;
  207 + var now = DateTime.Now;
  208 + if (baseTime.Value.Year != now.Year || baseTime.Value.Month != now.Month)
  209 + throw NCCException.Oh(message);
  210 + }
  211 +
181 private static async Task<int> GetIntConfigWithLegacyAsync( 212 private static async Task<int> GetIntConfigWithLegacyAsync(
182 ISqlSugarClient db, 213 ISqlSugarClient db,
183 string key, 214 string key,
@@ -199,5 +230,20 @@ namespace NCC.Extend.Helpers @@ -199,5 +230,20 @@ namespace NCC.Extend.Helpers
199 return defaultValue; 230 return defaultValue;
200 return int.TryParse(row.Value.Trim(), out var n) && n >= 0 ? n : defaultValue; 231 return int.TryParse(row.Value.Trim(), out var n) && n >= 0 ? n : defaultValue;
201 } 232 }
  233 +
  234 + private static async Task<bool> GetBoolConfigAsync(ISqlSugarClient db, string key, int defaultValue)
  235 + {
  236 + var row = await db.Queryable<SysConfigEntity>()
  237 + .Where(x => x.Category == CategorySysConfig && SqlFunc.ToLower(x.Key) == key.ToLower())
  238 + .FirstAsync();
  239 + if (row == null || string.IsNullOrWhiteSpace(row.Value))
  240 + return defaultValue == 1;
  241 + var value = row.Value.Trim();
  242 + if (value == "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase))
  243 + return true;
  244 + if (value == "0" || value.Equals("false", StringComparison.OrdinalIgnoreCase))
  245 + return false;
  246 + return int.TryParse(value, out var n) ? n == 1 : defaultValue == 1;
  247 + }
202 } 248 }
203 } 249 }
netcore/src/Modularity/Extend/NCC.Extend/LqKdKdjlbService.cs
@@ -197,6 +197,7 @@ namespace NCC.Extend.LqKdKdjlb @@ -197,6 +197,7 @@ namespace NCC.Extend.LqKdKdjlb
197 } 197 }
198 198
199 var memberArchiveNoDict = await BuildMemberArchiveNoDictAsync(billingRecords.Select(x => x.kdhy)); 199 var memberArchiveNoDict = await BuildMemberArchiveNoDictAsync(billingRecords.Select(x => x.kdhy));
  200 + var memberKhlxDict = await BuildMemberKhlxDictAsync(billingRecords.Select(x => x.kdhy));
200 201
201 _logger.LogInformation($"查询到 {billingRecords.Count} 条开单记录"); 202 _logger.LogInformation($"查询到 {billingRecords.Count} 条开单记录");
202 203
@@ -387,7 +388,7 @@ namespace NCC.Extend.LqKdKdjlb @@ -387,7 +388,7 @@ namespace NCC.Extend.LqKdKdjlb
387 CustomerArchiveNo = ResolveMemberArchiveNo(billing.kdhy, memberArchiveNoDict) ?? "无", 388 CustomerArchiveNo = ResolveMemberArchiveNo(billing.kdhy, memberArchiveNoDict) ?? "无",
388 CustomerChannel = string.IsNullOrEmpty(billing.kdhyjdqd) ? "无" : billing.kdhyjdqd, 389 CustomerChannel = string.IsNullOrEmpty(billing.kdhyjdqd) ? "无" : billing.kdhyjdqd,
389 GoldTriangle = GetGoldTriangleName(billing.jsj), 390 GoldTriangle = GetGoldTriangleName(billing.jsj),
390 - CustomerType = ResolveCustomerTypeName(billing.gjlx), 391 + CustomerType = BillingCustomerTypeHelper.ResolveCustomerTypeName(billing.gjlx, ResolveMemberKhlx(billing.kdhy, memberKhlxDict)),
391 HealthCoach = "无", 392 HealthCoach = "无",
392 PerformanceAmount = "¥0.00", 393 PerformanceAmount = "¥0.00",
393 RelatedItem = ItemDisplayNameHelper.Display(exportItemNameMap, itemDetail.px, itemDetail.pxmc) ?? "无", 394 RelatedItem = ItemDisplayNameHelper.Display(exportItemNameMap, itemDetail.px, itemDetail.pxmc) ?? "无",
@@ -422,7 +423,7 @@ namespace NCC.Extend.LqKdKdjlb @@ -422,7 +423,7 @@ namespace NCC.Extend.LqKdKdjlb
422 CustomerArchiveNo = ResolveMemberArchiveNo(billing.kdhy, memberArchiveNoDict) ?? "无", 423 CustomerArchiveNo = ResolveMemberArchiveNo(billing.kdhy, memberArchiveNoDict) ?? "无",
423 CustomerChannel = string.IsNullOrEmpty(billing.kdhyjdqd) ? "无" : billing.kdhyjdqd, 424 CustomerChannel = string.IsNullOrEmpty(billing.kdhyjdqd) ? "无" : billing.kdhyjdqd,
424 GoldTriangle = GetGoldTriangleName(billing.jsj), 425 GoldTriangle = GetGoldTriangleName(billing.jsj),
425 - CustomerType = ResolveCustomerTypeName(billing.gjlx), 426 + CustomerType = BillingCustomerTypeHelper.ResolveCustomerTypeName(billing.gjlx, ResolveMemberKhlx(billing.kdhy, memberKhlxDict)),
426 HealthCoach = healthTeacher.jksxm ?? "无", 427 HealthCoach = healthTeacher.jksxm ?? "无",
427 PerformanceAmount = FormatPerformanceAmount(healthTeacher.jksyj), 428 PerformanceAmount = FormatPerformanceAmount(healthTeacher.jksyj),
428 RelatedItem = ItemDisplayNameHelper.Display(exportItemNameMap, itemDetail.px, itemDetail.pxmc) ?? "无", 429 RelatedItem = ItemDisplayNameHelper.Display(exportItemNameMap, itemDetail.px, itemDetail.pxmc) ?? "无",
@@ -604,6 +605,7 @@ namespace NCC.Extend.LqKdKdjlb @@ -604,6 +605,7 @@ namespace NCC.Extend.LqKdKdjlb
604 var MemberInfo = await _db.Queryable<LqKhxxEntity>().FirstAsync(p => p.Id == entity.Kdhy); 605 var MemberInfo = await _db.Queryable<LqKhxxEntity>().FirstAsync(p => p.Id == entity.Kdhy);
605 output.kdhyc = MemberInfo.Khmc; 606 output.kdhyc = MemberInfo.Khmc;
606 output.kdhysjh = MemberInfo.Sjh; 607 output.kdhysjh = MemberInfo.Sjh;
  608 + output.gjlx = BillingCustomerTypeHelper.ResolveEffectiveGjlx(entity.Gjlx, MemberInfo?.Khlx) ?? output.gjlx;
607 609
608 if (!string.IsNullOrEmpty(entity.ActivityId)) 610 if (!string.IsNullOrEmpty(entity.ActivityId))
609 { 611 {
@@ -816,7 +818,7 @@ namespace NCC.Extend.LqKdKdjlb @@ -816,7 +818,7 @@ namespace NCC.Extend.LqKdKdjlb
816 { 818 {
817 var khxxMap = await _db.Queryable<LqKhxxEntity>() 819 var khxxMap = await _db.Queryable<LqKhxxEntity>()
818 .Where(x => kdhyIds.Contains(x.Id)) 820 .Where(x => kdhyIds.Contains(x.Id))
819 - .Select(x => new { x.Id, x.Khmc, x.Sjh }) 821 + .Select(x => new { x.Id, x.Khmc, x.Sjh, x.Khlx })
820 .ToListAsync(); 822 .ToListAsync();
821 var khxxDict = khxxMap.ToDictionary(x => x.Id); 823 var khxxDict = khxxMap.ToDictionary(x => x.Id);
822 foreach (var item in data.list) 824 foreach (var item in data.list)
@@ -825,6 +827,8 @@ namespace NCC.Extend.LqKdKdjlb @@ -825,6 +827,8 @@ namespace NCC.Extend.LqKdKdjlb
825 { 827 {
826 item.kdhyc = kh.Khmc; 828 item.kdhyc = kh.Khmc;
827 item.kdhysjh = kh.Sjh; 829 item.kdhysjh = kh.Sjh;
  830 + if (string.IsNullOrWhiteSpace(item.gjlx) && !string.IsNullOrWhiteSpace(kh.Khlx))
  831 + item.gjlx = kh.Khlx.Trim();
828 } 832 }
829 } 833 }
830 } 834 }
@@ -1348,6 +1352,7 @@ namespace NCC.Extend.LqKdKdjlb @@ -1348,6 +1352,7 @@ namespace NCC.Extend.LqKdKdjlb
1348 entity.CreateTime = DateTime.Now; 1352 entity.CreateTime = DateTime.Now;
1349 entity.UpdateTime = DateTime.Now; 1353 entity.UpdateTime = DateTime.Now;
1350 entity.IsEffective = StatusEnum.有效.GetHashCode(); 1354 entity.IsEffective = StatusEnum.有效.GetHashCode();
  1355 + await BillingCustomerTypeHelper.EnsureGjlxFromMemberAsync(_db, entity);
1351 try 1356 try
1352 { 1357 {
1353 //开启事务 1358 //开启事务
@@ -1739,6 +1744,7 @@ namespace NCC.Extend.LqKdKdjlb @@ -1739,6 +1744,7 @@ namespace NCC.Extend.LqKdKdjlb
1739 1744
1740 var entity = input.Adapt<LqKdKdjlbEntity>(); 1745 var entity = input.Adapt<LqKdKdjlbEntity>();
1741 entity.Id = id; // 确保ID正确设置 1746 entity.Id = id; // 确保ID正确设置
  1747 + await BillingCustomerTypeHelper.EnsureGjlxFromMemberAsync(_db, entity);
1742 try 1748 try
1743 { 1749 {
1744 //检查开单记录是否可以操作 1750 //检查开单记录是否可以操作
@@ -2477,6 +2483,13 @@ namespace NCC.Extend.LqKdKdjlb @@ -2477,6 +2483,13 @@ namespace NCC.Extend.LqKdKdjlb
2477 { 2483 {
2478 var batchData = historyData.Skip(batchIndex * batchSize).Take(batchSize).ToList(); 2484 var batchData = historyData.Skip(batchIndex * batchSize).Take(batchSize).ToList();
2479 2485
  2486 + var batchMemberIds = batchData
  2487 + .Select(x => (string)x.F_MemberId)
  2488 + .Where(x => !string.IsNullOrEmpty(x))
  2489 + .Distinct()
  2490 + .ToList();
  2491 + var batchMemberKhlxDict = await BuildMemberKhlxDictAsync(batchMemberIds);
  2492 +
2480 var kdjlbEntities = new List<LqKdKdjlbEntity>(); 2493 var kdjlbEntities = new List<LqKdKdjlbEntity>();
2481 var pxmxEntities = new List<LqKdPxmxEntity>(); 2494 var pxmxEntities = new List<LqKdPxmxEntity>();
2482 2495
@@ -2496,6 +2509,7 @@ namespace NCC.Extend.LqKdKdjlb @@ -2496,6 +2509,7 @@ namespace NCC.Extend.LqKdKdjlb
2496 Kdhyc = (string)item.会员名称, 2509 Kdhyc = (string)item.会员名称,
2497 Jsj = "暂无", 2510 Jsj = "暂无",
2498 Kdhysjh = (string)item.会员电话, 2511 Kdhysjh = (string)item.会员电话,
  2512 + Gjlx = ResolveMemberKhlx((string)item.F_MemberId, batchMemberKhlxDict),
2499 Kdrq = fixedDateTime, 2513 Kdrq = fixedDateTime,
2500 Zdyj = Convert.ToDecimal(item.整单业绩 ?? 0), 2514 Zdyj = Convert.ToDecimal(item.整单业绩 ?? 0),
2501 Sfyj = Convert.ToDecimal(item.实付业绩 ?? 0), 2515 Sfyj = Convert.ToDecimal(item.实付业绩 ?? 0),
@@ -3127,6 +3141,7 @@ namespace NCC.Extend.LqKdKdjlb @@ -3127,6 +3141,7 @@ namespace NCC.Extend.LqKdKdjlb
3127 3141
3128 var billingIds = billingRecords.Select(x => x.id).ToList(); 3142 var billingIds = billingRecords.Select(x => x.id).ToList();
3129 var memberArchiveNoDict = await BuildMemberArchiveNoDictAsync(billingRecords.Select(x => x.kdhy)); 3143 var memberArchiveNoDict = await BuildMemberArchiveNoDictAsync(billingRecords.Select(x => x.kdhy));
  3144 + var memberKhlxDict = await BuildMemberKhlxDictAsync(billingRecords.Select(x => x.kdhy));
3130 3145
3131 // 构建品项明细查询条件 3146 // 构建品项明细查询条件
3132 var itemDetailsQuery = _db.Queryable<LqKdPxmxEntity>() 3147 var itemDetailsQuery = _db.Queryable<LqKdPxmxEntity>()
@@ -3274,7 +3289,7 @@ namespace NCC.Extend.LqKdKdjlb @@ -3274,7 +3289,7 @@ namespace NCC.Extend.LqKdKdjlb
3274 customerChannel = billing.kdhyjdqd, // 客户进店渠道 3289 customerChannel = billing.kdhyjdqd, // 客户进店渠道
3275 storeId = billing.djmd, 3290 storeId = billing.djmd,
3276 goldTriangle = billing.jsj, 3291 goldTriangle = billing.jsj,
3277 - customerType = ResolveCustomerTypeName(billing.gjlx), 3292 + customerType = BillingCustomerTypeHelper.ResolveCustomerTypeName(billing.gjlx, ResolveMemberKhlx(billing.kdhy, memberKhlxDict)),
3278 cooperationInstitution = billing.hgjg, 3293 cooperationInstitution = billing.hgjg,
3279 cooperationInstitutionName = billing.hgjgName, 3294 cooperationInstitutionName = billing.hgjgName,
3280 paymentHospital = billing.fkyy, // 付款医院ID 3295 paymentHospital = billing.fkyy, // 付款医院ID
@@ -3408,23 +3423,12 @@ namespace NCC.Extend.LqKdKdjlb @@ -3408,23 +3423,12 @@ namespace NCC.Extend.LqKdKdjlb
3408 #endregion 3423 #endregion
3409 3424
3410 #region 私有方法 3425 #region 私有方法
3411 - /// <summary>  
3412 - /// 解析开单顾客类型名称  
3413 - /// </summary>  
3414 - private static string ResolveCustomerTypeName(string gjlx)  
3415 - {  
3416 - if (string.IsNullOrEmpty(gjlx))  
3417 - {  
3418 - return "无";  
3419 - }  
3420 -  
3421 - if (int.TryParse(gjlx, out var typeValue))  
3422 - {  
3423 - var desc = EnumHelper.GetEnumDesc<MemberTypeEnum>(typeValue);  
3424 - return string.IsNullOrEmpty(desc) ? gjlx : desc;  
3425 - }  
3426 3426
3427 - return gjlx; 3427 + private static string ResolveMemberKhlx(string memberId, Dictionary<string, string> memberKhlxDict)
  3428 + {
  3429 + if (string.IsNullOrEmpty(memberId) || memberKhlxDict == null)
  3430 + return null;
  3431 + return memberKhlxDict.TryGetValue(memberId, out var khlx) ? khlx : null;
3428 } 3432 }
3429 3433
3430 /// <summary> 3434 /// <summary>
@@ -3521,6 +3525,22 @@ namespace NCC.Extend.LqKdKdjlb @@ -3521,6 +3525,22 @@ namespace NCC.Extend.LqKdKdjlb
3521 return dict; 3525 return dict;
3522 } 3526 }
3523 3527
  3528 + private async Task<Dictionary<string, string>> BuildMemberKhlxDictAsync(IEnumerable<string> memberIds)
  3529 + {
  3530 + var ids = memberIds.Where(x => !string.IsNullOrEmpty(x)).Distinct().ToList();
  3531 + if (!ids.Any())
  3532 + return new Dictionary<string, string>();
  3533 +
  3534 + var members = await _db.Queryable<LqKhxxEntity>()
  3535 + .Where(x => ids.Contains(x.Id))
  3536 + .Select(x => new { x.Id, x.Khlx })
  3537 + .ToListAsync();
  3538 +
  3539 + return members
  3540 + .Where(x => !string.IsNullOrEmpty(x.Id) && !string.IsNullOrWhiteSpace(x.Khlx))
  3541 + .ToDictionary(x => x.Id, x => x.Khlx.Trim());
  3542 + }
  3543 +
3524 /// <summary> 3544 /// <summary>
3525 /// 根据开单会员字段解析客户档案号 3545 /// 根据开单会员字段解析客户档案号
3526 /// </summary> 3546 /// </summary>
netcore/src/Modularity/Extend/NCC.Extend/LqSmsService.cs
@@ -21,9 +21,11 @@ using NCC.Extend.Entitys.Enum; @@ -21,9 +21,11 @@ using NCC.Extend.Entitys.Enum;
21 using NCC.Extend.Entitys.lq_khxx; 21 using NCC.Extend.Entitys.lq_khxx;
22 using NCC.Extend.Entitys.lq_sms_send_log; 22 using NCC.Extend.Entitys.lq_sms_send_log;
23 using NCC.Extend.Entitys.lq_sms_template; 23 using NCC.Extend.Entitys.lq_sms_template;
  24 +using NCC.Extend.Entitys.lq_sms_template;
24 using NCC.Extend.Interfaces.BusinessOperationLog; 25 using NCC.Extend.Interfaces.BusinessOperationLog;
25 using NCC.Extend.Interfaces.LqSms; 26 using NCC.Extend.Interfaces.LqSms;
26 using NCC.FriendlyException; 27 using NCC.FriendlyException;
  28 +using NCC.System.Entitys.Permission;
27 using NCC.System.Entitys.System; 29 using NCC.System.Entitys.System;
28 using Newtonsoft.Json; 30 using Newtonsoft.Json;
29 using SqlSugar; 31 using SqlSugar;
@@ -683,18 +685,37 @@ namespace NCC.Extend @@ -683,18 +685,37 @@ namespace NCC.Extend
683 memberId = it.MemberId, 685 memberId = it.MemberId,
684 memberName = it.MemberName, 686 memberName = it.MemberName,
685 templateParam = it.TemplateParam, 687 templateParam = it.TemplateParam,
  688 + sendContent = it.SendContent,
686 status = it.Status, 689 status = it.Status,
687 providerResult = it.ProviderResult, 690 providerResult = it.ProviderResult,
688 bizDate = it.BizDate, 691 bizDate = it.BizDate,
689 isTest = it.IsTest, 692 isTest = it.IsTest,
690 - creatorTime = it.CreatorTime 693 + creatorTime = it.CreatorTime,
  694 + creatorUserId = it.CreatorUserId
691 }) 695 })
692 .ToPagedListAsync(input.currentPage, input.pageSize); 696 .ToPagedListAsync(input.currentPage, input.pageSize);
693 697
694 var mask = await IsPrivacyMobileMaskEnabledAsync(); 698 var mask = await IsPrivacyMobileMaskEnabledAsync();
  699 + var userIds = data.list
  700 + .Select(x => x.creatorUserId)
  701 + .Where(x => !string.IsNullOrWhiteSpace(x))
  702 + .Distinct()
  703 + .ToList();
  704 + var userMap = userIds.Count == 0
  705 + ? new Dictionary<string, UserEntity>()
  706 + : (await _db.Queryable<UserEntity>()
  707 + .Where(u => userIds.Contains(u.Id))
  708 + .ToListAsync())
  709 + .ToDictionary(u => u.Id, u => u);
  710 +
695 foreach (var item in data.list) 711 foreach (var item in data.list)
696 { 712 {
697 item.statusName = GetEnumDescription((SmsSendStatusEnum)item.status); 713 item.statusName = GetEnumDescription((SmsSendStatusEnum)item.status);
  714 + if (string.IsNullOrWhiteSpace(item.sendContent))
  715 + {
  716 + item.sendContent = FormatSendContent(item.signName, null, item.templateCode, item.templateParam);
  717 + }
  718 + item.creatorUserName = ResolveSenderDisplayName(item.creatorUserId, userMap);
698 // 默认存明文;开启隐私开关时再脱敏。历史已脱敏数据保持原样。 719 // 默认存明文;开启隐私开关时再脱敏。历史已脱敏数据保持原样。
699 if (mask) 720 if (mask)
700 { 721 {
@@ -723,6 +744,10 @@ namespace NCC.Extend @@ -723,6 +744,10 @@ namespace NCC.Extend
723 var configMap = await LoadConfigMapAsync(); 744 var configMap = await LoadConfigMapAsync();
724 var prevent = ParseSwitch(GetMapValue(configMap, KeyPreventDuplicate), 1) == 1; 745 var prevent = ParseSwitch(GetMapValue(configMap, KeyPreventDuplicate), 1) == 1;
725 var memberId = string.IsNullOrWhiteSpace(input.memberId) ? null : input.memberId.Trim(); 746 var memberId = string.IsNullOrWhiteSpace(input.memberId) ? null : input.memberId.Trim();
  747 + var defaultSign = GetMapValue(configMap, KeySignName);
  748 + var signName = string.IsNullOrWhiteSpace(template.SignName)
  749 + ? (string.IsNullOrWhiteSpace(defaultSign) ? DefaultSignName : defaultSign)
  750 + : template.SignName;
726 751
727 if (!skipDuplicate && prevent && !string.IsNullOrWhiteSpace(memberId)) 752 if (!skipDuplicate && prevent && !string.IsNullOrWhiteSpace(memberId))
728 { 753 {
@@ -735,7 +760,7 @@ namespace NCC.Extend @@ -735,7 +760,7 @@ namespace NCC.Extend
735 if (exists) 760 if (exists)
736 { 761 {
737 var skipLog = await InsertSendLogAsync(template, input, bizDate, 762 var skipLog = await InsertSendLogAsync(template, input, bizDate,
738 - (int)SmsSendStatusEnum.跳过, "同日同场景已成功发送,跳过", null); 763 + (int)SmsSendStatusEnum.跳过, "同日同场景已成功发送,跳过", null, signName);
739 return new 764 return new
740 { 765 {
741 logId = skipLog, 766 logId = skipLog,
@@ -758,11 +783,6 @@ namespace NCC.Extend @@ -758,11 +783,6 @@ namespace NCC.Extend
758 throw NCCException.Oh("当前生日/场景发送仅支持阿里云(厂商=1),请检查短信配置"); 783 throw NCCException.Oh("当前生日/场景发送仅支持阿里云(厂商=1),请检查短信配置");
759 } 784 }
760 785
761 - var defaultSign = GetMapValue(configMap, KeySignName);  
762 - var signName = string.IsNullOrWhiteSpace(template.SignName)  
763 - ? (string.IsNullOrWhiteSpace(defaultSign) ? DefaultSignName : defaultSign)  
764 - : template.SignName;  
765 -  
766 var variables = input.variables ?? new Dictionary<string, string>(); 786 var variables = input.variables ?? new Dictionary<string, string>();
767 if (!variables.ContainsKey("name") && !string.IsNullOrWhiteSpace(input.memberName)) 787 if (!variables.ContainsKey("name") && !string.IsNullOrWhiteSpace(input.memberName))
768 { 788 {
@@ -793,7 +813,7 @@ namespace NCC.Extend @@ -793,7 +813,7 @@ namespace NCC.Extend
793 813
794 var ok = string.Equals(providerResult, "OK", StringComparison.OrdinalIgnoreCase); 814 var ok = string.Equals(providerResult, "OK", StringComparison.OrdinalIgnoreCase);
795 var status = ok ? (int)SmsSendStatusEnum.成功 : (int)SmsSendStatusEnum.失败; 815 var status = ok ? (int)SmsSendStatusEnum.成功 : (int)SmsSendStatusEnum.失败;
796 - var logId = await InsertSendLogAsync(template, input, bizDate, status, providerResult, templateParam); 816 + var logId = await InsertSendLogAsync(template, input, bizDate, status, providerResult, templateParam, signName);
797 817
798 return new 818 return new
799 { 819 {
@@ -812,20 +832,23 @@ namespace NCC.Extend @@ -812,20 +832,23 @@ namespace NCC.Extend
812 DateTime bizDate, 832 DateTime bizDate,
813 int status, 833 int status,
814 string providerResult, 834 string providerResult,
815 - string templateParam) 835 + string templateParam,
  836 + string signName)
816 { 837 {
  838 + var resolvedSign = string.IsNullOrWhiteSpace(signName) ? template.SignName : signName;
817 var entity = new LqSmsSendLogEntity 839 var entity = new LqSmsSendLogEntity
818 { 840 {
819 Id = YitIdHelper.NextId().ToString(), 841 Id = YitIdHelper.NextId().ToString(),
820 SceneCode = template.SceneCode, 842 SceneCode = template.SceneCode,
821 TemplateId = template.Id, 843 TemplateId = template.Id,
822 TemplateCode = template.TemplateCode, 844 TemplateCode = template.TemplateCode,
823 - SignName = template.SignName, 845 + SignName = resolvedSign,
824 MobileMasked = string.IsNullOrWhiteSpace(input.mobile) ? string.Empty : input.mobile.Trim(), 846 MobileMasked = string.IsNullOrWhiteSpace(input.mobile) ? string.Empty : input.mobile.Trim(),
825 MobileHash = HashMobile(input.mobile), 847 MobileHash = HashMobile(input.mobile),
826 MemberId = string.IsNullOrWhiteSpace(input.memberId) ? null : input.memberId.Trim(), 848 MemberId = string.IsNullOrWhiteSpace(input.memberId) ? null : input.memberId.Trim(),
827 MemberName = input.memberName, 849 MemberName = input.memberName,
828 TemplateParam = templateParam, 850 TemplateParam = templateParam,
  851 + SendContent = FormatSendContent(resolvedSign, template.Name, template.TemplateCode, templateParam),
829 Status = status, 852 Status = status,
830 ProviderResult = providerResult, 853 ProviderResult = providerResult,
831 BizDate = bizDate.Date, 854 BizDate = bizDate.Date,
@@ -1106,6 +1129,51 @@ namespace NCC.Extend @@ -1106,6 +1129,51 @@ namespace NCC.Extend
1106 return text; 1129 return text;
1107 } 1130 }
1108 1131
  1132 + private static string FormatSendContent(string signName, string templateName, string templateCode, string templateParam)
  1133 + {
  1134 + var sb = new StringBuilder();
  1135 + if (!string.IsNullOrWhiteSpace(signName))
  1136 + {
  1137 + sb.Append('【').Append(signName.Trim()).Append('】');
  1138 + }
  1139 + if (!string.IsNullOrWhiteSpace(templateName))
  1140 + {
  1141 + if (sb.Length > 0) sb.Append(' ');
  1142 + sb.Append(templateName.Trim());
  1143 + }
  1144 + if (!string.IsNullOrWhiteSpace(templateCode))
  1145 + {
  1146 + if (sb.Length > 0) sb.Append(' ');
  1147 + sb.Append('(').Append(templateCode.Trim()).Append(')');
  1148 + }
  1149 + if (!string.IsNullOrWhiteSpace(templateParam))
  1150 + {
  1151 + if (sb.Length > 0) sb.Append(':');
  1152 + sb.Append(templateParam.Trim());
  1153 + }
  1154 + return sb.Length > 0 ? sb.ToString() : "无";
  1155 + }
  1156 +
  1157 + private static string ResolveSenderDisplayName(string creatorUserId, IDictionary<string, UserEntity> userMap)
  1158 + {
  1159 + if (string.IsNullOrWhiteSpace(creatorUserId))
  1160 + {
  1161 + return "系统";
  1162 + }
  1163 + if (userMap != null && userMap.TryGetValue(creatorUserId, out var user) && user != null)
  1164 + {
  1165 + if (!string.IsNullOrWhiteSpace(user.RealName))
  1166 + {
  1167 + return user.RealName.Trim();
  1168 + }
  1169 + if (!string.IsNullOrWhiteSpace(user.Account))
  1170 + {
  1171 + return user.Account.Trim();
  1172 + }
  1173 + }
  1174 + return "无";
  1175 + }
  1176 +
1109 private static string GetMapValue(Dictionary<string, string> map, string key) 1177 private static string GetMapValue(Dictionary<string, string> map, string key)
1110 { 1178 {
1111 if (map == null || string.IsNullOrWhiteSpace(key)) 1179 if (map == null || string.IsNullOrWhiteSpace(key))
netcore/src/Modularity/System/NCC.System.Entitys/Dto/System/SysConfig/SysConfigOutput.cs
@@ -309,6 +309,11 @@ namespace NCC.System.Entitys.Dto.System.SysConfig @@ -309,6 +309,11 @@ namespace NCC.System.Entitys.Dto.System.SysConfig
309 public string consumeEditableDays { get; set; } 309 public string consumeEditableDays { get; set; }
310 310
311 /// <summary> 311 /// <summary>
  312 + /// 小程序端是否允许跨月修改/作废/重开单(1=允许,0=不允许);关闭时即使仍在可修改天数内,耗卡月份与当前月不同也禁止操作
  313 + /// </summary>
  314 + public int? consumeAllowCrossMonthEdit { get; set; }
  315 +
  316 + /// <summary>
312 /// 管理后台作废耗卡允许天数;0 表示不限制天数(是否显示按钮由角色按钮权限控制) 317 /// 管理后台作废耗卡允许天数;0 表示不限制天数(是否显示按钮由角色按钮权限控制)
313 /// </summary> 318 /// </summary>
314 public string adminConsumeVoidEditableDays { get; set; } 319 public string adminConsumeVoidEditableDays { get; set; }
绿纤uni-app/common/auth-nav.js 0 → 100644
  1 +import auth from '@/common/auth.js'
  2 +
  3 +let redirectingToLogin = false
  4 +
  5 +/**
  6 + * 统一跳转登录页,避免多个接口同时 600 时连续 reLaunch 导致页面闪烁。
  7 + */
  8 +export function redirectToLogin() {
  9 + if (redirectingToLogin) return
  10 +
  11 + const pages = getCurrentPages()
  12 + const current = pages.length ? pages[pages.length - 1] : null
  13 + const route = current && (current.route || (current.$page && current.$page.fullPath))
  14 + if (route && String(route).indexOf('pages/login/login') !== -1) {
  15 + auth.clearLoginState()
  16 + return
  17 + }
  18 +
  19 + redirectingToLogin = true
  20 + auth.clearLoginState()
  21 + uni.reLaunch({
  22 + url: '/pages/login/login',
  23 + complete: () => {
  24 + setTimeout(() => {
  25 + redirectingToLogin = false
  26 + }, 800)
  27 + }
  28 + })
  29 +}
  30 +
  31 +export default {
  32 + redirectToLogin
  33 +}
绿纤uni-app/pages.json
@@ -7,25 +7,21 @@ @@ -7,25 +7,21 @@
7 }, 7 },
8 "pages": [ 8 "pages": [
9 { 9 {
10 - "path": "pages/home/home", 10 + "path": "pages/login/login",
11 "style": { 11 "style": {
12 - // "navigationBarTitleText": "绿纤工作台" 12 + "navigationBarTitleText": "绿纤协同平台",
13 "navigationStyle": "custom" 13 "navigationStyle": "custom"
14 } 14 }
15 }, 15 },
16 { 16 {
17 - "path": "pages/index/index", 17 + "path": "pages/home/home",
18 "style": { 18 "style": {
19 - "navigationBarTitleText": "绿纤协同平台", 19 + // "navigationBarTitleText": "绿纤工作台"
20 "navigationStyle": "custom" 20 "navigationStyle": "custom"
21 } 21 }
22 }, 22 },
23 -  
24 -  
25 -  
26 -  
27 { 23 {
28 - "path": "pages/login/login", 24 + "path": "pages/index/index",
29 "style": { 25 "style": {
30 "navigationBarTitleText": "绿纤协同平台", 26 "navigationBarTitleText": "绿纤协同平台",
31 "navigationStyle": "custom" 27 "navigationStyle": "custom"
绿纤uni-app/pages/appointment-detail/appointment-detail.vue
@@ -137,7 +137,7 @@ @@ -137,7 +137,7 @@
137 </view> 137 </view>
138 <view v-if="isAppointmentTransferred" class="action-buttons"> 138 <view v-if="isAppointmentTransferred" class="action-buttons">
139 <button class="action-btn secondary" @click="viewConsumeDetail" :disabled="!appointmentDetail.consumeId">查看耗卡</button> 139 <button class="action-btn secondary" @click="viewConsumeDetail" :disabled="!appointmentDetail.consumeId">查看耗卡</button>
140 - <button class="action-btn secondary" @click="onReopenConsume">重开单</button> 140 + <button v-if="canReopenConsume" class="action-btn secondary" @click="onReopenConsume">重开单</button>
141 <button class="action-btn primary" @click="onCompleteAppointment">完成</button> 141 <button class="action-btn primary" @click="onCompleteAppointment">完成</button>
142 <button class="action-btn primary" @click="openRemarkDialog">补充说明</button> 142 <button class="action-btn primary" @click="openRemarkDialog">补充说明</button>
143 </view> 143 </view>
@@ -249,6 +249,11 @@ @@ -249,6 +249,11 @@
249 import request from '@/service/request.js' 249 import request from '@/service/request.js'
250 import appointmentApi from '@/apis/modules/appointment.js' 250 import appointmentApi from '@/apis/modules/appointment.js'
251 import consumeApi from '@/apis/modules/consume.js' 251 import consumeApi from '@/apis/modules/consume.js'
  252 + import {
  253 + canConsumeMiniProgramEdit,
  254 + getConsumeEditablePolicy,
  255 + loadConsumeEditablePolicy
  256 + } from '@/utils/consume-editable-policy.js'
252 257
253 export default { 258 export default {
254 data() { 259 data() {
@@ -279,7 +284,8 @@ @@ -279,7 +284,8 @@
279 remarkSubmitting: false, 284 remarkSubmitting: false,
280 showCancelAppointmentDialog: false, 285 showCancelAppointmentDialog: false,
281 cancelAppointmentRemark: '', 286 cancelAppointmentRemark: '',
282 - cancelSubmitting: false 287 + cancelSubmitting: false,
  288 + consumeEditablePolicy: getConsumeEditablePolicy()
283 } 289 }
284 }, 290 },
285 computed: { 291 computed: {
@@ -306,8 +312,18 @@ @@ -306,8 +312,18 @@
306 if (s === '已取消') return 'failed'; 312 if (s === '已取消') return 'failed';
307 if (s === '已完成') return 'success'; 313 if (s === '已完成') return 'success';
308 return 'pending'; 314 return 'pending';
  315 + },
  316 + canReopenConsume() {
  317 + const row = this.getRelatedConsumeRow();
  318 + if (!row) return false;
  319 + return canConsumeMiniProgramEdit(row, this.consumeEditablePolicy);
309 } 320 }
310 }, 321 },
  322 + onShow() {
  323 + loadConsumeEditablePolicy().then(policy => {
  324 + this.consumeEditablePolicy = policy;
  325 + });
  326 + },
311 onLoad(options) { 327 onLoad(options) {
312 this.initializePage(options); 328 this.initializePage(options);
313 }, 329 },
@@ -386,6 +402,19 @@ @@ -386,6 +402,19 @@
386 return String(val); 402 return String(val);
387 }, 403 },
388 404
  405 + getRelatedConsumeRow() {
  406 + const detail = this.appointmentDetail;
  407 + if (!detail) return null;
  408 + const list = detail.relatedConsumeList || [];
  409 + if (list.length > 0) {
  410 + return list.find(item => item && item.hksj) || list[0];
  411 + }
  412 + if (detail.consumeId && detail.consumeHksj) {
  413 + return { id: detail.consumeId, hksj: detail.consumeHksj };
  414 + }
  415 + return null;
  416 + },
  417 +
389 formatOptionalTime(t) { 418 formatOptionalTime(t) {
390 if (!t) return '无'; 419 if (!t) return '无';
391 try { 420 try {
绿纤uni-app/pages/consume-detail/consume-detail.vue
@@ -178,7 +178,7 @@ @@ -178,7 +178,7 @@
178 178
179 </view> 179 </view>
180 <!-- 操作按钮区域 --> 180 <!-- 操作按钮区域 -->
181 - <view class="action-buttons" v-if="consumeData && consumeData.isEffective == '1' && isCurrentMonth && newuserInfo.gw != '科技老师'"> 181 + <view class="action-buttons" v-if="consumeData && canShowConsumeActions && newuserInfo.gw != '科技老师'">
182 <button 182 <button
183 class="action-btn cancel-btn" 183 class="action-btn cancel-btn"
184 @click="handleCancel" 184 @click="handleCancel"
@@ -239,6 +239,11 @@ @@ -239,6 +239,11 @@
239 <script> 239 <script>
240 import consumeApi from '@/apis/modules/consume.js' 240 import consumeApi from '@/apis/modules/consume.js'
241 import config from '@/common/config.js' 241 import config from '@/common/config.js'
  242 + import {
  243 + canConsumeMiniProgramEdit,
  244 + getConsumeEditablePolicy,
  245 + loadConsumeEditablePolicy
  246 + } from '@/utils/consume-editable-policy.js'
242 247
243 export default { 248 export default {
244 data() { 249 data() {
@@ -253,24 +258,21 @@ @@ -253,24 +258,21 @@
253 cancelLoading: false, 258 cancelLoading: false,
254 reopenLoading: false, 259 reopenLoading: false,
255 cancelRemark: '', // 作废备注 260 cancelRemark: '', // 作废备注
256 - showCancelDialog: false // 显示作废对话框 261 + showCancelDialog: false, // 显示作废对话框
  262 + consumeEditablePolicy: getConsumeEditablePolicy()
257 } 263 }
258 }, 264 },
259 265
260 computed: { 266 computed: {
261 - // 判断耗卡日期是否在本月  
262 - isCurrentMonth() {  
263 - if(this.isopen) {  
264 - return this.isopen  
265 - } else{  
266 - if (!this.consumeData || !this.consumeData.hksj) return false  
267 -  
268 - const consumeDate = new Date(this.consumeData.hksj)  
269 - const currentDate = new Date()  
270 -  
271 - return consumeDate.getFullYear() === currentDate.getFullYear() &&  
272 - consumeDate.getMonth() === currentDate.getMonth() 267 + // 是否允许展示修改/作废(与后台可修改天数、跨月配置一致;菜单开关可绕过)
  268 + canShowConsumeActions() {
  269 + if (this.isopen) {
  270 + return true
  271 + }
  272 + if (!this.consumeData || this.consumeData.isEffective != '1') {
  273 + return false
273 } 274 }
  275 + return canConsumeMiniProgramEdit(this.consumeData, this.consumeEditablePolicy)
274 } 276 }
275 }, 277 },
276 278
@@ -283,6 +285,9 @@ @@ -283,6 +285,9 @@
283 }, 285 },
284 onShow() { 286 onShow() {
285 this.getisopen() 287 this.getisopen()
  288 + loadConsumeEditablePolicy().then(policy => {
  289 + this.consumeEditablePolicy = policy
  290 + })
286 this.loadConsumeDetail() 291 this.loadConsumeDetail()
287 }, 292 },
288 293
绿纤uni-app/pages/home/home.vue
@@ -94,6 +94,8 @@ @@ -94,6 +94,8 @@
94 import CustomTabBar from '@/components/custom-tab-bar/index.vue' 94 import CustomTabBar from '@/components/custom-tab-bar/index.vue'
95 import CustomNavbar from '@/components/custom-navbar/custom-navbar.vue' 95 import CustomNavbar from '@/components/custom-navbar/custom-navbar.vue'
96 import { normalizeAppPageUrl, buildWebViewPageUrl } from '@/common/page-route.js' 96 import { normalizeAppPageUrl, buildWebViewPageUrl } from '@/common/page-route.js'
  97 + import auth from '@/common/auth.js'
  98 + import { redirectToLogin } from '@/common/auth-nav.js'
97 99
98 export default { 100 export default {
99 components: { 101 components: {
@@ -737,15 +739,11 @@ @@ -737,15 +739,11 @@
737 }, 739 },
738 // 检查登录状态 740 // 检查登录状态
739 checkLoginStatus() { 741 checkLoginStatus() {
740 - const token = uni.getStorageSync('token')  
741 - if (!token) {  
742 - uni.reLaunch({  
743 - url: '/pages/login/login'  
744 - }) 742 + if (!auth.getToken()) {
  743 + redirectToLogin()
745 return 744 return
746 - } else {  
747 - this.userInfo = uni.getStorageSync('userInfo')  
748 } 745 }
  746 + this.userInfo = uni.getStorageSync('userInfo')
749 }, 747 },
750 // 页面跳转 748 // 页面跳转
751 goToPage(url) { 749 goToPage(url) {
绿纤uni-app/pages/index/index.vue
@@ -248,6 +248,8 @@ @@ -248,6 +248,8 @@
248 import memberApi from '@/apis/modules/member.js' 248 import memberApi from '@/apis/modules/member.js'
249 import performanceApi from '@/apis/modules/performance.js' 249 import performanceApi from '@/apis/modules/performance.js'
250 import { normalizeAppPageUrl, buildWebViewPageUrl } from '@/common/page-route.js' 250 import { normalizeAppPageUrl, buildWebViewPageUrl } from '@/common/page-route.js'
  251 + import auth from '@/common/auth.js'
  252 + import { redirectToLogin } from '@/common/auth-nav.js'
251 export default { 253 export default {
252 data() { 254 data() {
253 return { 255 return {
@@ -353,16 +355,11 @@ @@ -353,16 +355,11 @@
353 355
354 // 检查登录状态 356 // 检查登录状态
355 checkLoginStatus() { 357 checkLoginStatus() {
356 - const token = uni.getStorageSync('token')  
357 -  
358 - if (!token) {  
359 - uni.reLaunch({  
360 - url: '/pages/login/login'  
361 - }) 358 + if (!auth.getToken()) {
  359 + redirectToLogin()
362 return 360 return
363 - } else {  
364 - this.userInfo = uni.getStorageSync('userInfo');  
365 } 361 }
  362 + this.userInfo = uni.getStorageSync('userInfo');
366 }, 363 },
367 364
368 // 显示加载效果 365 // 显示加载效果
绿纤uni-app/pages/login/login.vue
@@ -191,11 +191,29 @@ export default { @@ -191,11 +191,29 @@ export default {
191 forcePwd2: '', 191 forcePwd2: '',
192 forceShowPwd: false, 192 forceShowPwd: false,
193 forceSubmitting: false, 193 forceSubmitting: false,
194 - forcePwdUserId: '' 194 + forcePwdUserId: '',
  195 + /** 已登录自动进首页,避免重复 reLaunch */
  196 + autoEntering: false
195 } 197 }
196 }, 198 },
197 - onLoad() {}, 199 + onLoad() {
  200 + this.tryAutoEnterHome()
  201 + },
  202 + onShow() {
  203 + this.tryAutoEnterHome()
  204 + },
198 methods: { 205 methods: {
  206 + tryAutoEnterHome() {
  207 + if (this.autoEntering) return
  208 + if (!auth.getToken()) return
  209 + this.autoEntering = true
  210 + uni.reLaunch({
  211 + url: '/pages/home/home',
  212 + complete: () => {
  213 + this.autoEntering = false
  214 + }
  215 + })
  216 + },
199 clearRememberedCredentials() { 217 clearRememberedCredentials() {
200 uni.removeStorageSync('rememberPassword') 218 uni.removeStorageSync('rememberPassword')
201 uni.removeStorageSync('rememberedUsername') 219 uni.removeStorageSync('rememberedUsername')
绿纤uni-app/pagesA/lx/lx.vue
@@ -784,6 +784,20 @@ @@ -784,6 +784,20 @@
784 this.supplementBillingId = options.supplementBillingId; 784 this.supplementBillingId = options.supplementBillingId;
785 this.kdhy = options.kdhy 785 this.kdhy = options.kdhy
786 this.qk = options.qk || 0 786 this.qk = options.qk || 0
  787 + if (options.kdhy) {
  788 + this.selectedValues.kdhy = options.kdhy
  789 + try {
  790 + const memberRes = await this.API.getMemberDetail(options.kdhy)
  791 + if (memberRes && memberRes.code === 200 && memberRes.data) {
  792 + this.formData.kdhy = memberRes.data.khmc || ''
  793 + this.formData.kdhysjh = memberRes.data.sjh || ''
  794 + this.formData.kdhyc = memberRes.data.khmc || ''
  795 + this.formData.gjlx = memberRes.data.khlx || ''
  796 + }
  797 + } catch (e) {
  798 + console.error('补缴开单加载会员类型失败', e)
  799 + }
  800 + }
787 // 设置默认日期为当前日期 801 // 设置默认日期为当前日期
788 this.formData.kdrq = this.utils.gettime().substring(0, 10); 802 this.formData.kdrq = this.utils.gettime().substring(0, 10);
789 // 添加默认的品项行 803 // 添加默认的品项行
绿纤uni-app/service/request.js
1 import service from './service.js' 1 import service from './service.js'
2 import config from '../common/config.js' 2 import config from '../common/config.js'
3 import auth from '@/common/auth.js' 3 import auth from '@/common/auth.js'
  4 +import { redirectToLogin } from '@/common/auth-nav.js'
4 const get = function(url, data) { 5 const get = function(url, data) {
5 return request(url, 'GET', data); 6 return request(url, 'GET', data);
6 } 7 }
@@ -60,10 +61,7 @@ const request = function(url, method, data, headers, timeoutMs, options) { @@ -60,10 +61,7 @@ const request = function(url, method, data, headers, timeoutMs, options) {
60 if (res.statusCode === 200) { 61 if (res.statusCode === 200) {
61 const biz = res.data || {} 62 const biz = res.data || {}
62 if (!opts.anonymous && (biz.code == 600 || biz.msg == '登录过期,请重新登录')) { 63 if (!opts.anonymous && (biz.code == 600 || biz.msg == '登录过期,请重新登录')) {
63 - auth.clearLoginState()  
64 - uni.reLaunch({  
65 - url: '/pages/login/login'  
66 - }); 64 + redirectToLogin()
67 } 65 }
68 if (biz.code < 0 && biz.message) { 66 if (biz.code < 0 && biz.message) {
69 uni.showToast({ 67 uni.showToast({
绿纤uni-app/utils/consume-editable-policy.js 0 → 100644
  1 +import sysConfigApi from '@/apis/modules/sys-config.js'
  2 +
  3 +export const CONSUME_EDITABLE_POLICY_KEY = 'consumeEditablePolicy'
  4 +
  5 +const DEFAULT_POLICY = {
  6 + editableDays: 3,
  7 + allowCrossMonth: false
  8 +}
  9 +
  10 +export function parseConsumeEditablePolicy(data = {}) {
  11 + const days = parseInt(data.consumeEditableDays, 10)
  12 + return {
  13 + editableDays: isNaN(days) || days < 1 ? DEFAULT_POLICY.editableDays : days,
  14 + allowCrossMonth: Number(data.consumeAllowCrossMonthEdit) === 1
  15 + }
  16 +}
  17 +
  18 +/**
  19 + * 与后端 ConsumeEditableDaysHelper 口径一致:先校验天数,再校验跨月
  20 + */
  21 +export function canConsumeMiniProgramEdit(row, policy = DEFAULT_POLICY) {
  22 + if (!row) return false
  23 + const baseTime = parseConsumeBaseTime(row)
  24 + if (!baseTime) return false
  25 +
  26 + const now = Date.now()
  27 + if (now > baseTime.getTime() + policy.editableDays * 86400000) {
  28 + return false
  29 + }
  30 +
  31 + if (!policy.allowCrossMonth) {
  32 + const nowDate = new Date()
  33 + if (
  34 + baseTime.getFullYear() !== nowDate.getFullYear() ||
  35 + baseTime.getMonth() !== nowDate.getMonth()
  36 + ) {
  37 + return false
  38 + }
  39 + }
  40 +
  41 + return true
  42 +}
  43 +
  44 +export function parseConsumeBaseTime(row) {
  45 + const raw = row.hksj || row.Hksj || row.createTime || row.CreateTime
  46 + if (!raw) return null
  47 + const date = new Date(raw)
  48 + return isNaN(date.getTime()) ? null : date
  49 +}
  50 +
  51 +export function loadConsumeEditablePolicy(force = false) {
  52 + if (!force) {
  53 + const cached = uni.getStorageSync(CONSUME_EDITABLE_POLICY_KEY)
  54 + if (cached && typeof cached === 'object') {
  55 + refreshConsumeEditablePolicy()
  56 + return Promise.resolve(cached)
  57 + }
  58 + }
  59 + return refreshConsumeEditablePolicy()
  60 +}
  61 +
  62 +export function refreshConsumeEditablePolicy() {
  63 + return sysConfigApi.getSysConfig()
  64 + .then(res => {
  65 + const policy = parseConsumeEditablePolicy((res && res.data) || {})
  66 + uni.setStorageSync(CONSUME_EDITABLE_POLICY_KEY, policy)
  67 + uni.$emit && uni.$emit('consume-editable-policy-change', policy)
  68 + return policy
  69 + })
  70 + .catch(() => {
  71 + const cached = uni.getStorageSync(CONSUME_EDITABLE_POLICY_KEY)
  72 + return cached && typeof cached === 'object' ? cached : { ...DEFAULT_POLICY }
  73 + })
  74 +}
  75 +
  76 +export function getConsumeEditablePolicy() {
  77 + const cached = uni.getStorageSync(CONSUME_EDITABLE_POLICY_KEY)
  78 + return cached && typeof cached === 'object' ? cached : { ...DEFAULT_POLICY }
  79 +}
  80 +
  81 +export function clearConsumeEditablePolicy() {
  82 + uni.removeStorageSync(CONSUME_EDITABLE_POLICY_KEY)
  83 +}
  84 +
  85 +export default {
  86 + CONSUME_EDITABLE_POLICY_KEY,
  87 + parseConsumeEditablePolicy,
  88 + canConsumeMiniProgramEdit,
  89 + parseConsumeBaseTime,
  90 + loadConsumeEditablePolicy,
  91 + refreshConsumeEditablePolicy,
  92 + getConsumeEditablePolicy,
  93 + clearConsumeEditablePolicy
  94 +}
项目文档相关/docs/数据库说明.md
@@ -493,6 +493,7 @@ @@ -493,6 +493,7 @@
493 493
494 - **系统参数**(`BASE_SYSCONFIG`,`Category`=`SysConfig`): 494 - **系统参数**(`BASE_SYSCONFIG`,`Category`=`SysConfig`):
495 - `consumeEditableDays`:小程序端耗卡可修改/作废/重开单天数,默认 **3**。 495 - `consumeEditableDays`:小程序端耗卡可修改/作废/重开单天数,默认 **3**。
  496 + - `consumeAllowCrossMonthEdit`:小程序端是否允许跨月修改/作废/重开单,**1=允许 / 0=不允许**,默认 **0**;关闭时耗卡月份与当前月不同即禁止(即使仍在可修改天数内)。
496 - `adminConsumeVoidEditableDays`:管理后台作废耗卡允许天数,默认 **0**(0=不限制);按钮权限 `btn_cancel`。 497 - `adminConsumeVoidEditableDays`:管理后台作废耗卡允许天数,默认 **0**(0=不限制);按钮权限 `btn_cancel`。
497 - `adminConsumeDeleteEditableDays`:管理后台删除耗卡允许天数,默认 **0**;按钮权限 `btn_remove`。 498 - `adminConsumeDeleteEditableDays`:管理后台删除耗卡允许天数,默认 **0**;按钮权限 `btn_remove`。
498 - 已废弃合并项 `adminConsumeCancelEditableDays`(读取天数时兼容)。 499 - 已废弃合并项 `adminConsumeCancelEditableDays`(读取天数时兼容)。
@@ -606,6 +607,7 @@ @@ -606,6 +607,7 @@
606 - `F_MobileHash`:手机号哈希(防重比对) 607 - `F_MobileHash`:手机号哈希(防重比对)
607 - `F_MemberId` / `F_MemberName`:会员(生日等业务) 608 - `F_MemberId` / `F_MemberName`:会员(生日等业务)
608 - `F_TemplateParam`:实际参数 JSON 609 - `F_TemplateParam`:实际参数 JSON
  610 + - `F_SendContent`:发送内容摘要(签名 + 模板名称/CODE + 参数,便于日志查看)
609 - `F_Status`:0 失败 / 1 成功 / 2 跳过 611 - `F_Status`:0 失败 / 1 成功 / 2 跳过
610 - `F_ProviderResult`:厂商返回说明 612 - `F_ProviderResult`:厂商返回说明
611 - `F_BizDate`:业务日期(生日防重按日) 613 - `F_BizDate`:业务日期(生日防重按日)
项目文档相关/sql/2026-07-31/lq_kd_kdjlb_回填顾客类型gjlx.sql 0 → 100644
  1 +-- 开单记录 gjlx(顾客类型)为空时,从会员档案 khlx 回填快照
  2 +-- 原因:历史导入/部分开单入口未写入 gjlx,台账「客户类型」显示为「无」
  3 +-- 执行前请备份;建议在业务低峰执行
  4 +
  5 +UPDATE lq_kd_kdjlb k
  6 +INNER JOIN lq_khxx kh ON kh.F_Id = k.kdhy
  7 +SET k.gjlx = kh.khlx
  8 +WHERE (k.gjlx IS NULL OR TRIM(k.gjlx) = '')
  9 + AND kh.khlx IS NOT NULL
  10 + AND TRIM(kh.khlx) <> '';
项目文档相关/sql/2026-07-31/lq_sms_send_log_发送内容字段.sql 0 → 100644
  1 +-- 短信发送日志:增加发送内容摘要字段(便于日志列表展示)
  2 +-- 执行库:lqerp_dev / 生产对应库
  3 +
  4 +ALTER TABLE `lq_sms_send_log`
  5 + ADD COLUMN `F_SendContent` varchar(1024) DEFAULT NULL COMMENT '发送内容摘要(签名+模板+参数)' AFTER `F_TemplateParam`;