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 41 }
42 42  
43 43 intField('consumeEditableDays', 3)
  44 + intField('consumeAllowCrossMonthEdit', 0)
44 45 intField('adminConsumeVoidEditableDays', 0)
45 46 intField('adminConsumeDeleteEditableDays', 0)
46 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 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 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 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 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 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 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 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 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 237 </template>
279 238  
280 239 <script>
281 240 import request from '@/utils/request'
  241 +import ConfigPanelShell from './config-panel-shell.vue'
282 242 import ConfigRuleRow from './config-rule-row'
283 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 255 function parseIdList(raw) {
286 256 if (!raw) return []
287 257 return String(raw)
... ... @@ -292,7 +262,7 @@ function parseIdList(raw) {
292 262  
293 263 export default {
294 264 name: 'BusinessConfigPanel',
295   - components: { ConfigRuleRow, CommissionTierHelpPreview },
  265 + components: { ConfigPanelShell, ConfigRuleRow, CommissionTierHelpPreview },
296 266 props: {
297 267 form: {
298 268 type: Object,
... ... @@ -309,7 +279,7 @@ export default {
309 279 },
310 280 data() {
311 281 return {
312   - activeModules: ['consume', 'tk', 'sleepRemind', 'trainingCheckin', 'memberTag', 'monitorCenter', 'goldTriangle'],
  282 + modules: MODULES,
313 283 itemOptions: [],
314 284 itemLoading: false,
315 285 itemOptionsLoaded: false
... ... @@ -332,7 +302,6 @@ export default {
332 302 immediate: true,
333 303 handler(val) {
334 304 const ids = parseIdList(val)
335   - // 配置页按单品项展示;若历史存了多个,只回显并保留第一项
336 305 if (ids.length > 1) {
337 306 this.$set(this.form, 'tkGiftItemIds', ids[0])
338 307 }
... ... @@ -392,14 +361,14 @@ export default {
392 361 this.itemOptions.forEach(item => {
393 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 372 this.itemOptions = Object.keys(map).map(k => map[k])
404 373 }
405 374 }
... ... @@ -407,75 +376,14 @@ export default {
407 376 </script>
408 377  
409 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 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 389 .config-section {
... ... @@ -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 444 .config-section {
544 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 3 <el-form ref="baseForm" :model="baseForm" label-width="120px">
4 4 <el-tabs v-model="activeName" type="border-card" class="NCC-el_tabs">
5 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 18 </div>
99 19 </el-tab-pane>
100 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 32 </el-tab-pane>
170 33 <el-tab-pane label="短信设置" name="sms">
171   - <sms-config-panel />
  34 + <div class="config-tab-panel">
  35 + <sms-config-panel />
  36 + </div>
172 37 </el-tab-pane>
173 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 48 </el-tab-pane>
226 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 53 </el-tab-pane>
229 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 63 </el-tab-pane>
264 64 </el-tabs>
265 65 </el-form>
... ... @@ -273,6 +73,10 @@ import {
273 73 } from '@/api/system/sysConfig'
274 74 import { getPayConfig, savePayConfig } from '@/api/extend/mallConfig'
275 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 80 import SmsConfigPanel from './sms-config-panel'
277 81 import { normalizeBusinessConfigFields } from '@/utils/consume-admin-policy'
278 82 import { ossUploadFile } from '@/utils/oss-upload'
... ... @@ -281,7 +85,14 @@ import getPageTitle from &#39;@/utils/get-page-title&#39;
281 85  
282 86 export default {
283 87 name: 'system-sysConfig',
284   - components: { BusinessConfigPanel, SmsConfigPanel },
  88 + components: {
  89 + BusinessConfigPanel,
  90 + BasicConfigPanel,
  91 + SecurityConfigPanel,
  92 + AiConfigPanel,
  93 + MallPayConfigPanel,
  94 + SmsConfigPanel
  95 + },
285 96 data() {
286 97 return {
287 98 defaultParaformerSubmitUrl: 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription',
... ... @@ -291,6 +102,7 @@ export default {
291 102 logoUploading: false,
292 103 logoIconUploading: false,
293 104 mallPayLoading: false,
  105 + mallPayReady: false,
294 106 mallPayForm: {
295 107 MchId: '',
296 108 ApiV3Key: '',
... ... @@ -354,6 +166,7 @@ export default {
354 166 aiChatApiKey: '',
355 167 aiSummarizeSystemPrompt: '',
356 168 consumeEditableDays: 3,
  169 + consumeAllowCrossMonthEdit: 0,
357 170 adminConsumeVoidEditableDays: 0,
358 171 adminConsumeDeleteEditableDays: 0,
359 172 abnormalConsumeAmountTolerance: 0.01,
... ... @@ -377,6 +190,7 @@ export default {
377 190 methods: {
378 191 // 加载会员商城支付配置(Category=MallPayment)
379 192 initMallPayConfig() {
  193 + this.mallPayLoading = true
380 194 getPayConfig().then(res => {
381 195 const data = (res && res.data) || {}
382 196 this.mallPayForm = {
... ... @@ -387,7 +201,10 @@ export default {
387 201 NotifyUrl: data.notifyUrl || '',
388 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 210 saveMallPay() {
... ... @@ -421,6 +238,9 @@ export default {
421 238 this.baseForm.commissionTierHelpSwitch = this.baseForm.commissionTierHelpSwitch == null
422 239 ? 1
423 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 244 this.baseForm = normalizeBusinessConfigFields(this.baseForm)
425 245 this.syncSysBrand()
426 246 this.listLoading = false
... ... @@ -493,6 +313,9 @@ export default {
493 313 payload.commissionTierHelpSwitch = payload.commissionTierHelpSwitch == null
494 314 ? 1
495 315 : (Number(payload.commissionTierHelpSwitch) === 1 ? 1 : 0)
  316 + payload.consumeAllowCrossMonthEdit = payload.consumeAllowCrossMonthEdit == null
  317 + ? 0
  318 + : (Number(payload.consumeAllowCrossMonthEdit) === 1 ? 1 : 0)
496 319 updateSystemConfig(payload).then(res => {
497 320 this.syncSysBrand()
498 321 this.$message({
... ... @@ -525,115 +348,23 @@ export default {
525 348 }
526 349  
527 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 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 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 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 71 <el-input
25   - v-model="configForm.accessKeySecret"
26   - show-password
  72 + v-model="templateQuery.name"
  73 + size="small"
27 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 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 151 <sms-template-dialog ref="templateDialog" @success="loadTemplates" />
151 152 <sms-test-send-dialog ref="testDialog" />
... ... @@ -154,6 +155,7 @@
154 155 </template>
155 156  
156 157 <script>
  158 +import ConfigPanelShell from './config-panel-shell.vue'
157 159 import {
158 160 getSmsConfig,
159 161 saveSmsConfig,
... ... @@ -165,15 +167,22 @@ import SmsTemplateDialog from &#39;./sms-template-dialog.vue&#39;
165 167 import SmsTestSendDialog from './sms-test-send-dialog.vue'
166 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 175 export default {
169 176 name: 'SmsConfigPanel',
170 177 components: {
  178 + ConfigPanelShell,
171 179 SmsTemplateDialog,
172 180 SmsTestSendDialog,
173 181 SmsSendLogDrawer
174 182 },
175 183 data() {
176 184 return {
  185 + modules: MODULES,
177 186 loading: false,
178 187 configSaving: false,
179 188 templateLoading: false,
... ... @@ -310,73 +319,73 @@ export default {
310 319 </script>
311 320  
312 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 335 display: flex;
351   - justify-content: space-between;
352 336 align-items: center;
353   - flex-wrap: wrap;
354 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 347 color: #909399;
372 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 389 border-color: rgba(255, 255, 255, 0.08);
381 390  
382 391 &__tag {
... ...
antis-ncc-admin/src/views/system/sysConfig/sms-send-log-drawer.vue
... ... @@ -2,7 +2,7 @@
2 2 <el-drawer
3 3 title="发送日志"
4 4 :visible.sync="visible"
5   - size="720px"
  5 + size="75%"
6 6 append-to-body
7 7 :wrapper-closable="false"
8 8 custom-class="sms-send-log-drawer"
... ... @@ -15,7 +15,7 @@
15 15 size="small"
16 16 clearable
17 17 placeholder="场景"
18   - style="width: 140px; margin-right: 8px"
  18 + class="toolbar-item toolbar-item--scene"
19 19 >
20 20 <el-option label="Birthday" value="Birthday" />
21 21 <el-option label="Login" value="Login" />
... ... @@ -27,7 +27,7 @@
27 27 size="small"
28 28 clearable
29 29 placeholder="状态"
30   - style="width: 120px; margin-right: 8px"
  30 + class="toolbar-item toolbar-item--status"
31 31 >
32 32 <el-option label="成功" :value="1" />
33 33 <el-option label="失败" :value="0" />
... ... @@ -41,38 +41,41 @@
41 41 range-separator="至"
42 42 start-placeholder="业务日起"
43 43 end-placeholder="业务日止"
44   - style="margin-right: 8px; width: 260px"
  44 + class="toolbar-item toolbar-item--date"
45 45 />
46 46 <el-button size="small" icon="el-icon-search" @click="search">查询</el-button>
47 47 <el-button size="small" icon="el-icon-refresh" @click="reset">重置</el-button>
48 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 52 <template slot-scope="{ row }">
53 53 {{ row.creatorTime ? ncc.dateFormat(row.creatorTime, 'YYYY-MM-DD HH:mm:ss') : '无' }}
54 54 </template>
55 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 57 <template slot-scope="{ row }">{{ row.sceneCode || '无' }}</template>
58 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 60 <template slot-scope="{ row }">{{ $maskMobile(row.mobileMasked) || '无' }}</template>
64 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 63 <template slot-scope="{ row }">{{ row.memberName || '无' }}</template>
67 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 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 76 </template>
74 77 </el-table-column>
75   - <el-table-column label="状态" width="90" align="center">
  78 + <el-table-column label="状态" width="100" align="center">
76 79 <template slot-scope="{ row }">
77 80 <el-tag
78 81 size="mini"
... ... @@ -81,6 +84,7 @@
81 84 >
82 85 {{ row.statusName || statusName(row.status) }}
83 86 </el-tag>
  87 + <el-tag v-if="row.isTest === 1" size="mini" type="warning" disable-transitions>测试</el-tag>
84 88 </template>
85 89 </el-table-column>
86 90 <el-table-column prop="providerResult" label="结果" min-width="140" show-overflow-tooltip>
... ... @@ -177,6 +181,14 @@ export default {
177 181 statusName(status) {
178 182 const map = { 0: '失败', 1: '成功', 2: '跳过' }
179 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 196  
185 197 <style lang="scss" scoped>
186 198 .sms-send-log-body {
  199 + display: flex;
  200 + flex-direction: column;
187 201 padding: 0 16px 16px;
188 202 height: 100%;
189 203 box-sizing: border-box;
190   - overflow: auto;
  204 + overflow: hidden;
191 205 }
192 206  
193 207 .sms-send-log-toolbar {
194 208 display: flex;
195 209 flex-wrap: wrap;
196 210 align-items: center;
  211 + gap: 8px;
197 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 231 </style>
200 232  
201 233 <style lang="scss">
  234 +/*
  235 + * 发送日志抽屉 append-to-body,表格不在 #app 内,全局 #app .el-table 41px 不生效。
  236 + * 与 common.scss / NCC-table 行高规范对齐。
  237 + */
202 238 .sms-send-log-drawer {
203 239 .el-drawer__body {
204 240 padding: 0;
205 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 324 </style>
... ...
netcore/src/Modularity/Extend/NCC.Extend.Entitys/Dto/LqSms/LqSmsSendLogListOutput.cs
... ... @@ -15,11 +15,20 @@ namespace NCC.Extend.Entitys.Dto.LqSms
15 15 public string memberId { get; set; }
16 16 public string memberName { get; set; }
17 17 public string templateParam { get; set; }
  18 + /// <summary>
  19 + /// 发送内容摘要
  20 + /// </summary>
  21 + public string sendContent { get; set; }
18 22 public int status { get; set; }
19 23 public string statusName { get; set; }
20 24 public string providerResult { get; set; }
21 25 public DateTime? bizDate { get; set; }
22 26 public int isTest { get; set; }
23 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 73 public string TemplateParam { get; set; }
74 74  
75 75 /// <summary>
  76 + /// 发送内容摘要(签名 + 模板 + 参数,便于日志查看)
  77 + /// </summary>
  78 + [SugarColumn(ColumnName = "F_SendContent", IsNullable = true)]
  79 + public string SendContent { get; set; }
  80 +
  81 + /// <summary>
76 82 /// 发送状态(见 <see cref="SmsSendStatusEnum"/>)
77 83 /// </summary>
78 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 23 public const string KeyConsumeEditableDays = "consumeEditableDays";
24 24  
25 25 /// <summary>
  26 + /// 配置键:小程序端是否允许跨月修改/作废/重开单(1=允许,0=不允许)
  27 + /// </summary>
  28 + public const string KeyConsumeAllowCrossMonthEdit = "consumeAllowCrossMonthEdit";
  29 +
  30 + /// <summary>
26 31 /// 配置键:管理后台作废耗卡允许天数
27 32 /// </summary>
28 33 public const string KeyAdminConsumeVoidEditableDays = "adminConsumeVoidEditableDays";
... ... @@ -63,6 +68,11 @@ namespace NCC.Extend.Helpers
63 68 public const int DefaultDays = 3;
64 69  
65 70 /// <summary>
  71 + /// 小程序端缺省:不允许跨月修改
  72 + /// </summary>
  73 + public const int DefaultConsumeAllowCrossMonthEdit = 0;
  74 +
  75 + /// <summary>
66 76 /// 管理后台缺省天数:0 表示不限制
67 77 /// </summary>
68 78 public const int DefaultAdminOperationEditableDays = 0;
... ... @@ -81,12 +91,23 @@ namespace NCC.Extend.Helpers
81 91 }
82 92  
83 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 103 /// </summary>
86 104 public static async Task EnsureConsumeWithinEditableWindowAsync(ISqlSugarClient db, LqXhHyhkEntity card)
87 105 {
88 106 var days = await GetConsumeEditableDaysAsync(db);
89 107 EnsureWithinEditableWindow(card, days, "耗卡发生已超过 {0} 天,不允许修改/作废");
  108 + var allowCrossMonth = await GetConsumeAllowCrossMonthEditAsync(db);
  109 + if (!allowCrossMonth)
  110 + EnsureSameCalendarMonth(card, "耗卡已跨月,不允许修改/作废");
90 111 }
91 112  
92 113 /// <summary>
... ... @@ -178,6 +199,16 @@ namespace NCC.Extend.Helpers
178 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 212 private static async Task<int> GetIntConfigWithLegacyAsync(
182 213 ISqlSugarClient db,
183 214 string key,
... ... @@ -199,5 +230,20 @@ namespace NCC.Extend.Helpers
199 230 return defaultValue;
200 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 197 }
198 198  
199 199 var memberArchiveNoDict = await BuildMemberArchiveNoDictAsync(billingRecords.Select(x => x.kdhy));
  200 + var memberKhlxDict = await BuildMemberKhlxDictAsync(billingRecords.Select(x => x.kdhy));
200 201  
201 202 _logger.LogInformation($"查询到 {billingRecords.Count} 条开单记录");
202 203  
... ... @@ -387,7 +388,7 @@ namespace NCC.Extend.LqKdKdjlb
387 388 CustomerArchiveNo = ResolveMemberArchiveNo(billing.kdhy, memberArchiveNoDict) ?? "无",
388 389 CustomerChannel = string.IsNullOrEmpty(billing.kdhyjdqd) ? "无" : billing.kdhyjdqd,
389 390 GoldTriangle = GetGoldTriangleName(billing.jsj),
390   - CustomerType = ResolveCustomerTypeName(billing.gjlx),
  391 + CustomerType = BillingCustomerTypeHelper.ResolveCustomerTypeName(billing.gjlx, ResolveMemberKhlx(billing.kdhy, memberKhlxDict)),
391 392 HealthCoach = "无",
392 393 PerformanceAmount = "¥0.00",
393 394 RelatedItem = ItemDisplayNameHelper.Display(exportItemNameMap, itemDetail.px, itemDetail.pxmc) ?? "无",
... ... @@ -422,7 +423,7 @@ namespace NCC.Extend.LqKdKdjlb
422 423 CustomerArchiveNo = ResolveMemberArchiveNo(billing.kdhy, memberArchiveNoDict) ?? "无",
423 424 CustomerChannel = string.IsNullOrEmpty(billing.kdhyjdqd) ? "无" : billing.kdhyjdqd,
424 425 GoldTriangle = GetGoldTriangleName(billing.jsj),
425   - CustomerType = ResolveCustomerTypeName(billing.gjlx),
  426 + CustomerType = BillingCustomerTypeHelper.ResolveCustomerTypeName(billing.gjlx, ResolveMemberKhlx(billing.kdhy, memberKhlxDict)),
426 427 HealthCoach = healthTeacher.jksxm ?? "无",
427 428 PerformanceAmount = FormatPerformanceAmount(healthTeacher.jksyj),
428 429 RelatedItem = ItemDisplayNameHelper.Display(exportItemNameMap, itemDetail.px, itemDetail.pxmc) ?? "无",
... ... @@ -604,6 +605,7 @@ namespace NCC.Extend.LqKdKdjlb
604 605 var MemberInfo = await _db.Queryable<LqKhxxEntity>().FirstAsync(p => p.Id == entity.Kdhy);
605 606 output.kdhyc = MemberInfo.Khmc;
606 607 output.kdhysjh = MemberInfo.Sjh;
  608 + output.gjlx = BillingCustomerTypeHelper.ResolveEffectiveGjlx(entity.Gjlx, MemberInfo?.Khlx) ?? output.gjlx;
607 609  
608 610 if (!string.IsNullOrEmpty(entity.ActivityId))
609 611 {
... ... @@ -816,7 +818,7 @@ namespace NCC.Extend.LqKdKdjlb
816 818 {
817 819 var khxxMap = await _db.Queryable<LqKhxxEntity>()
818 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 822 .ToListAsync();
821 823 var khxxDict = khxxMap.ToDictionary(x => x.Id);
822 824 foreach (var item in data.list)
... ... @@ -825,6 +827,8 @@ namespace NCC.Extend.LqKdKdjlb
825 827 {
826 828 item.kdhyc = kh.Khmc;
827 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 1352 entity.CreateTime = DateTime.Now;
1349 1353 entity.UpdateTime = DateTime.Now;
1350 1354 entity.IsEffective = StatusEnum.有效.GetHashCode();
  1355 + await BillingCustomerTypeHelper.EnsureGjlxFromMemberAsync(_db, entity);
1351 1356 try
1352 1357 {
1353 1358 //开启事务
... ... @@ -1739,6 +1744,7 @@ namespace NCC.Extend.LqKdKdjlb
1739 1744  
1740 1745 var entity = input.Adapt<LqKdKdjlbEntity>();
1741 1746 entity.Id = id; // 确保ID正确设置
  1747 + await BillingCustomerTypeHelper.EnsureGjlxFromMemberAsync(_db, entity);
1742 1748 try
1743 1749 {
1744 1750 //检查开单记录是否可以操作
... ... @@ -2477,6 +2483,13 @@ namespace NCC.Extend.LqKdKdjlb
2477 2483 {
2478 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 2493 var kdjlbEntities = new List<LqKdKdjlbEntity>();
2481 2494 var pxmxEntities = new List<LqKdPxmxEntity>();
2482 2495  
... ... @@ -2496,6 +2509,7 @@ namespace NCC.Extend.LqKdKdjlb
2496 2509 Kdhyc = (string)item.会员名称,
2497 2510 Jsj = "暂无",
2498 2511 Kdhysjh = (string)item.会员电话,
  2512 + Gjlx = ResolveMemberKhlx((string)item.F_MemberId, batchMemberKhlxDict),
2499 2513 Kdrq = fixedDateTime,
2500 2514 Zdyj = Convert.ToDecimal(item.整单业绩 ?? 0),
2501 2515 Sfyj = Convert.ToDecimal(item.实付业绩 ?? 0),
... ... @@ -3127,6 +3141,7 @@ namespace NCC.Extend.LqKdKdjlb
3127 3141  
3128 3142 var billingIds = billingRecords.Select(x => x.id).ToList();
3129 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 3147 var itemDetailsQuery = _db.Queryable<LqKdPxmxEntity>()
... ... @@ -3274,7 +3289,7 @@ namespace NCC.Extend.LqKdKdjlb
3274 3289 customerChannel = billing.kdhyjdqd, // 客户进店渠道
3275 3290 storeId = billing.djmd,
3276 3291 goldTriangle = billing.jsj,
3277   - customerType = ResolveCustomerTypeName(billing.gjlx),
  3292 + customerType = BillingCustomerTypeHelper.ResolveCustomerTypeName(billing.gjlx, ResolveMemberKhlx(billing.kdhy, memberKhlxDict)),
3278 3293 cooperationInstitution = billing.hgjg,
3279 3294 cooperationInstitutionName = billing.hgjgName,
3280 3295 paymentHospital = billing.fkyy, // 付款医院ID
... ... @@ -3408,23 +3423,12 @@ namespace NCC.Extend.LqKdKdjlb
3408 3423 #endregion
3409 3424  
3410 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 3434 /// <summary>
... ... @@ -3521,6 +3525,22 @@ namespace NCC.Extend.LqKdKdjlb
3521 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 3544 /// <summary>
3525 3545 /// 根据开单会员字段解析客户档案号
3526 3546 /// </summary>
... ...
netcore/src/Modularity/Extend/NCC.Extend/LqSmsService.cs
... ... @@ -21,9 +21,11 @@ using NCC.Extend.Entitys.Enum;
21 21 using NCC.Extend.Entitys.lq_khxx;
22 22 using NCC.Extend.Entitys.lq_sms_send_log;
23 23 using NCC.Extend.Entitys.lq_sms_template;
  24 +using NCC.Extend.Entitys.lq_sms_template;
24 25 using NCC.Extend.Interfaces.BusinessOperationLog;
25 26 using NCC.Extend.Interfaces.LqSms;
26 27 using NCC.FriendlyException;
  28 +using NCC.System.Entitys.Permission;
27 29 using NCC.System.Entitys.System;
28 30 using Newtonsoft.Json;
29 31 using SqlSugar;
... ... @@ -683,18 +685,37 @@ namespace NCC.Extend
683 685 memberId = it.MemberId,
684 686 memberName = it.MemberName,
685 687 templateParam = it.TemplateParam,
  688 + sendContent = it.SendContent,
686 689 status = it.Status,
687 690 providerResult = it.ProviderResult,
688 691 bizDate = it.BizDate,
689 692 isTest = it.IsTest,
690   - creatorTime = it.CreatorTime
  693 + creatorTime = it.CreatorTime,
  694 + creatorUserId = it.CreatorUserId
691 695 })
692 696 .ToPagedListAsync(input.currentPage, input.pageSize);
693 697  
694 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 711 foreach (var item in data.list)
696 712 {
697 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 720 if (mask)
700 721 {
... ... @@ -723,6 +744,10 @@ namespace NCC.Extend
723 744 var configMap = await LoadConfigMapAsync();
724 745 var prevent = ParseSwitch(GetMapValue(configMap, KeyPreventDuplicate), 1) == 1;
725 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 752 if (!skipDuplicate && prevent && !string.IsNullOrWhiteSpace(memberId))
728 753 {
... ... @@ -735,7 +760,7 @@ namespace NCC.Extend
735 760 if (exists)
736 761 {
737 762 var skipLog = await InsertSendLogAsync(template, input, bizDate,
738   - (int)SmsSendStatusEnum.跳过, "同日同场景已成功发送,跳过", null);
  763 + (int)SmsSendStatusEnum.跳过, "同日同场景已成功发送,跳过", null, signName);
739 764 return new
740 765 {
741 766 logId = skipLog,
... ... @@ -758,11 +783,6 @@ namespace NCC.Extend
758 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 786 var variables = input.variables ?? new Dictionary<string, string>();
767 787 if (!variables.ContainsKey("name") && !string.IsNullOrWhiteSpace(input.memberName))
768 788 {
... ... @@ -793,7 +813,7 @@ namespace NCC.Extend
793 813  
794 814 var ok = string.Equals(providerResult, "OK", StringComparison.OrdinalIgnoreCase);
795 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 818 return new
799 819 {
... ... @@ -812,20 +832,23 @@ namespace NCC.Extend
812 832 DateTime bizDate,
813 833 int status,
814 834 string providerResult,
815   - string templateParam)
  835 + string templateParam,
  836 + string signName)
816 837 {
  838 + var resolvedSign = string.IsNullOrWhiteSpace(signName) ? template.SignName : signName;
817 839 var entity = new LqSmsSendLogEntity
818 840 {
819 841 Id = YitIdHelper.NextId().ToString(),
820 842 SceneCode = template.SceneCode,
821 843 TemplateId = template.Id,
822 844 TemplateCode = template.TemplateCode,
823   - SignName = template.SignName,
  845 + SignName = resolvedSign,
824 846 MobileMasked = string.IsNullOrWhiteSpace(input.mobile) ? string.Empty : input.mobile.Trim(),
825 847 MobileHash = HashMobile(input.mobile),
826 848 MemberId = string.IsNullOrWhiteSpace(input.memberId) ? null : input.memberId.Trim(),
827 849 MemberName = input.memberName,
828 850 TemplateParam = templateParam,
  851 + SendContent = FormatSendContent(resolvedSign, template.Name, template.TemplateCode, templateParam),
829 852 Status = status,
830 853 ProviderResult = providerResult,
831 854 BizDate = bizDate.Date,
... ... @@ -1106,6 +1129,51 @@ namespace NCC.Extend
1106 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 1177 private static string GetMapValue(Dictionary<string, string> map, string key)
1110 1178 {
1111 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 309 public string consumeEditableDays { get; set; }
310 310  
311 311 /// <summary>
  312 + /// 小程序端是否允许跨月修改/作废/重开单(1=允许,0=不允许);关闭时即使仍在可修改天数内,耗卡月份与当前月不同也禁止操作
  313 + /// </summary>
  314 + public int? consumeAllowCrossMonthEdit { get; set; }
  315 +
  316 + /// <summary>
312 317 /// 管理后台作废耗卡允许天数;0 表示不限制天数(是否显示按钮由角色按钮权限控制)
313 318 /// </summary>
314 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 7 },
8 8 "pages": [
9 9 {
10   - "path": "pages/home/home",
  10 + "path": "pages/login/login",
11 11 "style": {
12   - // "navigationBarTitleText": "绿纤工作台"
  12 + "navigationBarTitleText": "绿纤协同平台",
13 13 "navigationStyle": "custom"
14 14 }
15 15 },
16 16 {
17   - "path": "pages/index/index",
  17 + "path": "pages/home/home",
18 18 "style": {
19   - "navigationBarTitleText": "绿纤协同平台",
  19 + // "navigationBarTitleText": "绿纤工作台"
20 20 "navigationStyle": "custom"
21 21 }
22 22 },
23   -
24   -
25   -
26   -
27 23 {
28   - "path": "pages/login/login",
  24 + "path": "pages/index/index",
29 25 "style": {
30 26 "navigationBarTitleText": "绿纤协同平台",
31 27 "navigationStyle": "custom"
... ...
绿纤uni-app/pages/appointment-detail/appointment-detail.vue
... ... @@ -137,7 +137,7 @@
137 137 </view>
138 138 <view v-if="isAppointmentTransferred" class="action-buttons">
139 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 141 <button class="action-btn primary" @click="onCompleteAppointment">完成</button>
142 142 <button class="action-btn primary" @click="openRemarkDialog">补充说明</button>
143 143 </view>
... ... @@ -249,6 +249,11 @@
249 249 import request from '@/service/request.js'
250 250 import appointmentApi from '@/apis/modules/appointment.js'
251 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 258 export default {
254 259 data() {
... ... @@ -279,7 +284,8 @@
279 284 remarkSubmitting: false,
280 285 showCancelAppointmentDialog: false,
281 286 cancelAppointmentRemark: '',
282   - cancelSubmitting: false
  287 + cancelSubmitting: false,
  288 + consumeEditablePolicy: getConsumeEditablePolicy()
283 289 }
284 290 },
285 291 computed: {
... ... @@ -306,8 +312,18 @@
306 312 if (s === '已取消') return 'failed';
307 313 if (s === '已完成') return 'success';
308 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 327 onLoad(options) {
312 328 this.initializePage(options);
313 329 },
... ... @@ -386,6 +402,19 @@
386 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 418 formatOptionalTime(t) {
390 419 if (!t) return '无';
391 420 try {
... ...
绿纤uni-app/pages/consume-detail/consume-detail.vue
... ... @@ -178,7 +178,7 @@
178 178  
179 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 182 <button
183 183 class="action-btn cancel-btn"
184 184 @click="handleCancel"
... ... @@ -239,6 +239,11 @@
239 239 <script>
240 240 import consumeApi from '@/apis/modules/consume.js'
241 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 248 export default {
244 249 data() {
... ... @@ -253,24 +258,21 @@
253 258 cancelLoading: false,
254 259 reopenLoading: false,
255 260 cancelRemark: '', // 作废备注
256   - showCancelDialog: false // 显示作废对话框
  261 + showCancelDialog: false, // 显示作废对话框
  262 + consumeEditablePolicy: getConsumeEditablePolicy()
257 263 }
258 264 },
259 265  
260 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 285 },
284 286 onShow() {
285 287 this.getisopen()
  288 + loadConsumeEditablePolicy().then(policy => {
  289 + this.consumeEditablePolicy = policy
  290 + })
286 291 this.loadConsumeDetail()
287 292 },
288 293  
... ...
绿纤uni-app/pages/home/home.vue
... ... @@ -94,6 +94,8 @@
94 94 import CustomTabBar from '@/components/custom-tab-bar/index.vue'
95 95 import CustomNavbar from '@/components/custom-navbar/custom-navbar.vue'
96 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 100 export default {
99 101 components: {
... ... @@ -737,15 +739,11 @@
737 739 },
738 740 // 检查登录状态
739 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 744 return
746   - } else {
747   - this.userInfo = uni.getStorageSync('userInfo')
748 745 }
  746 + this.userInfo = uni.getStorageSync('userInfo')
749 747 },
750 748 // 页面跳转
751 749 goToPage(url) {
... ...
绿纤uni-app/pages/index/index.vue
... ... @@ -248,6 +248,8 @@
248 248 import memberApi from '@/apis/modules/member.js'
249 249 import performanceApi from '@/apis/modules/performance.js'
250 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 253 export default {
252 254 data() {
253 255 return {
... ... @@ -353,16 +355,11 @@
353 355  
354 356 // 检查登录状态
355 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 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 191 forcePwd2: '',
192 192 forceShowPwd: false,
193 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 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 217 clearRememberedCredentials() {
200 218 uni.removeStorageSync('rememberPassword')
201 219 uni.removeStorageSync('rememberedUsername')
... ...
绿纤uni-app/pagesA/lx/lx.vue
... ... @@ -784,6 +784,20 @@
784 784 this.supplementBillingId = options.supplementBillingId;
785 785 this.kdhy = options.kdhy
786 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 802 this.formData.kdrq = this.utils.gettime().substring(0, 10);
789 803 // 添加默认的品项行
... ...
绿纤uni-app/service/request.js
1 1 import service from './service.js'
2 2 import config from '../common/config.js'
3 3 import auth from '@/common/auth.js'
  4 +import { redirectToLogin } from '@/common/auth-nav.js'
4 5 const get = function(url, data) {
5 6 return request(url, 'GET', data);
6 7 }
... ... @@ -60,10 +61,7 @@ const request = function(url, method, data, headers, timeoutMs, options) {
60 61 if (res.statusCode === 200) {
61 62 const biz = res.data || {}
62 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 66 if (biz.code < 0 && biz.message) {
69 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 493  
494 494 - **系统参数**(`BASE_SYSCONFIG`,`Category`=`SysConfig`):
495 495 - `consumeEditableDays`:小程序端耗卡可修改/作废/重开单天数,默认 **3**。
  496 + - `consumeAllowCrossMonthEdit`:小程序端是否允许跨月修改/作废/重开单,**1=允许 / 0=不允许**,默认 **0**;关闭时耗卡月份与当前月不同即禁止(即使仍在可修改天数内)。
496 497 - `adminConsumeVoidEditableDays`:管理后台作废耗卡允许天数,默认 **0**(0=不限制);按钮权限 `btn_cancel`。
497 498 - `adminConsumeDeleteEditableDays`:管理后台删除耗卡允许天数,默认 **0**;按钮权限 `btn_remove`。
498 499 - 已废弃合并项 `adminConsumeCancelEditableDays`(读取天数时兼容)。
... ... @@ -606,6 +607,7 @@
606 607 - `F_MobileHash`:手机号哈希(防重比对)
607 608 - `F_MemberId` / `F_MemberName`:会员(生日等业务)
608 609 - `F_TemplateParam`:实际参数 JSON
  610 + - `F_SendContent`:发送内容摘要(签名 + 模板名称/CODE + 参数,便于日志查看)
609 611 - `F_Status`:0 失败 / 1 成功 / 2 跳过
610 612 - `F_ProviderResult`:厂商返回说明
611 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`;
... ...