init project
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_APP_TITLE=Gao Customer H5
|
||||||
|
VITE_USE_LOCAL_TIMI_WEB=true
|
||||||
|
VITE_API_BASE_URL=http://localhost:8091
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_APP_TITLE=Gao Customer H5
|
||||||
|
VITE_API_BASE_URL=https://api.imyeyu.com
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2022: true,
|
||||||
|
node: true
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:vue/vue3-essential",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"./.eslintrc-auto-import.json"
|
||||||
|
],
|
||||||
|
parser: "vue-eslint-parser",
|
||||||
|
parserOptions: {
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
extraFileExtensions: [".vue"]
|
||||||
|
},
|
||||||
|
plugins: ["vue", "@typescript-eslint"],
|
||||||
|
rules: {
|
||||||
|
"camelcase": "error",
|
||||||
|
"comma-dangle": ["error", "never"],
|
||||||
|
"eqeqeq": ["error", "always"],
|
||||||
|
"max-len": ["error", { code: 220 }],
|
||||||
|
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||||
|
"no-debugger": "warn",
|
||||||
|
"no-tabs": ["error", { allowIndentationTabs: true }],
|
||||||
|
"quotes": ["error", "double"],
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"space-before-function-paren": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"anonymous": "always",
|
||||||
|
"named": "never",
|
||||||
|
"asyncArrow": "always"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
"vue/no-v-html": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off"
|
||||||
|
}
|
||||||
|
};
|
||||||
+14
@@ -1,3 +1,7 @@
|
|||||||
|
/AGENTS.md
|
||||||
|
/.npmrc
|
||||||
|
/.env.production
|
||||||
|
|
||||||
# ---> Maven
|
# ---> Maven
|
||||||
target/
|
target/
|
||||||
pom.xml.tag
|
pom.xml.tag
|
||||||
@@ -122,3 +126,13 @@ fabric.properties
|
|||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
replay_pid*
|
replay_pid*
|
||||||
|
|
||||||
|
# ---> Node / Vite
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
auto-imports.d.ts
|
||||||
|
components.d.ts
|
||||||
|
.eslintrc-auto-import.json
|
||||||
|
|
||||||
|
|||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="HtmlUnknownTag" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="myValues">
|
||||||
|
<value>
|
||||||
|
<list size="7">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="nobr" />
|
||||||
|
<item index="1" class="java.lang.String" itemvalue="noembed" />
|
||||||
|
<item index="2" class="java.lang.String" itemvalue="comment" />
|
||||||
|
<item index="3" class="java.lang.String" itemvalue="noscript" />
|
||||||
|
<item index="4" class="java.lang.String" itemvalue="embed" />
|
||||||
|
<item index="5" class="java.lang.String" itemvalue="script" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
<option name="myCustomValuesEnabled" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/gao-customer-h5.iml" filepath="$PROJECT_DIR$/.idea/gao-customer-h5.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- 项目用途:基于 Vue 3 + Vite + TypeScript + TDesign 的移动端 NAS 管理项目,包含文件管理、服务器状态、文件读取等功能。
|
||||||
|
- 前端栈:Vue 3 Composition API(setup 语法糖)、Vite、TypeScript、Pinia、Vue Router、Axios、Less、TDesign。
|
||||||
|
- 后端接口上下文:对接后端源码位于 E:\IDEAProject\TimiServerAPI\src\main\java\com\imyeyu\api\modules\system\controller。
|
||||||
|
- 代码结构:主要业务页面位于 src/pages,下分 customer、management 等目录;入口与路由位于 src/main.ts、src/router/index.ts;存在 Pinia store。
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- 所有沟通、文案、注释使用中文;文件编码使用 UTF-8。
|
||||||
|
- 前端使用 Vue 3 Composition API + setup,代码按功能分块组织,公共变量/函数在前。
|
||||||
|
- Vue 自定义组件标签使用全小写;模板中动态文本优先使用 v-text。
|
||||||
|
- Less 必须使用层级写法,属性尽量缩写,单位优先 rem,小数写法如 .5。
|
||||||
|
- 尽量定义 TypeScript 类型和必要的 JSDoc;Promise 写法优先 async/await。
|
||||||
|
- 不轻易引入外部依赖,优先官方 API;需要兼容深色模式。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- 安装依赖:npm install
|
||||||
|
- 本地开发:npm run dev
|
||||||
|
- 生产构建:npm run build
|
||||||
|
- 预览构建:npm run preview
|
||||||
|
- ESLint 检查:npm run lint
|
||||||
|
- TypeScript 检查:npm run type-check
|
||||||
|
- Windows 常用命令:Get-ChildItem、Get-Content、rg、git status、git diff
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- 完成前端改动后,至少执行 npm run build 或 npm run type-check 验证没有明显编译错误。
|
||||||
|
- 如涉及样式和交互,检查深色模式与移动端布局,避免固定底部按钮遮挡内容。
|
||||||
|
- 不主动添加测试;若无法运行验证命令,需要在最终说明中明确指出。
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# the name by which the project can be referenced within Serena
|
||||||
|
project_name: "gao-customer-h5"
|
||||||
|
|
||||||
|
|
||||||
|
# list of languages for which language servers are started; choose from:
|
||||||
|
# al bash clojure cpp csharp
|
||||||
|
# csharp_omnisharp dart elixir elm erlang
|
||||||
|
# fortran fsharp go groovy haskell
|
||||||
|
# haxe java julia kotlin lua
|
||||||
|
# markdown
|
||||||
|
# matlab nix pascal perl php
|
||||||
|
# php_phpactor powershell python python_jedi r
|
||||||
|
# rego ruby ruby_solargraph rust scala
|
||||||
|
# swift terraform toml typescript typescript_vts
|
||||||
|
# vue yaml zig
|
||||||
|
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||||
|
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||||
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
|
# Note:
|
||||||
|
# - For C, use cpp
|
||||||
|
# - For JavaScript, use typescript
|
||||||
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
|
# Special requirements:
|
||||||
|
# Some languages require additional setup/installations.
|
||||||
|
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||||
|
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||||
|
# The first language is the default language and the respective language server will be used as a fallback.
|
||||||
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
|
languages: []
|
||||||
|
|
||||||
|
# the encoding used by text files in the project
|
||||||
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||||
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
|
line_ending:
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# whether to use project's .gitignore files to ignore files
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# advanced configuration option allowing to configure language server-specific options.
|
||||||
|
# Maps the language key to the options.
|
||||||
|
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||||
|
# No documentation on options means no options are available.
|
||||||
|
ls_specific_settings: {}
|
||||||
|
|
||||||
|
# list of additional paths to ignore in this project.
|
||||||
|
# Same syntax as gitignore, so you can use * and **.
|
||||||
|
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude.
|
||||||
|
# This extends the existing exclusions (e.g. from the global configuration)
|
||||||
|
#
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project based on the project name or path.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
|
||||||
|
# for example by saying that the information retrieved from a memory file is no longer correct
|
||||||
|
# or no longer relevant for the project.
|
||||||
|
# * `edit_memory`: Replaces content matching a regular expression in a memory.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_file`: Finds files in the given relative paths
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
|
||||||
|
# * `find_symbol`: Performs a global (or local) search using the language server backend.
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
|
||||||
|
# for clients that do not read the initial instructions when the MCP server is connected.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
|
||||||
|
# is relevant to the current task. You can infer whether the information
|
||||||
|
# is relevant from the memory file name.
|
||||||
|
# You should not read the same memory file multiple times in the same conversation.
|
||||||
|
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
|
||||||
|
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
|
||||||
|
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
|
||||||
|
# For JB, we use a separate tool.
|
||||||
|
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
|
||||||
|
# * `safe_delete_symbol`:
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
|
||||||
|
# The memory name should be meaningful.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||||
|
# This extends the existing inclusions (e.g. from the global configuration).
|
||||||
|
included_optional_tools: []
|
||||||
|
|
||||||
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
|
fixed_tools: []
|
||||||
|
|
||||||
|
# list of mode names to that are always to be included in the set of active modes
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this setting overrides the global configuration.
|
||||||
|
# Set this to [] to disable base modes for this project.
|
||||||
|
# Set this to a list of mode names to always include the respective modes for this project.
|
||||||
|
base_modes:
|
||||||
|
|
||||||
|
# list of mode names that are to be activated by default.
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
default_modes:
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||||
|
# such as docstrings or parameter information.
|
||||||
|
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||||
|
# If null or missing, use the setting from the global configuration.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
|
|
||||||
|
# list of regex patterns for memories to completely ignore.
|
||||||
|
# Matching memories will not appear in list_memories or activate_project output
|
||||||
|
# and cannot be accessed via read_memory or write_memory.
|
||||||
|
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
# Example: ["_archive/.*", "_episodes/.*"]
|
||||||
|
ignored_memory_patterns: []
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
# dtgao-customer-h5
|
# gao-customer-h5
|
||||||
|
|
||||||
|
基于 Vue 3、Vite、TypeScript、Pinia、Vue Router、TDesign Mobile Vue 的移动端项目初始化骨架。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import tsParser from "@typescript-eslint/parser";
|
||||||
|
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||||
|
import vuePlugin from "eslint-plugin-vue";
|
||||||
|
import vueParser from "vue-eslint-parser";
|
||||||
|
import autoImportConfig from "./.eslintrc-auto-import.json" with { type: "json" };
|
||||||
|
|
||||||
|
const browserGlobals = {
|
||||||
|
document: "readonly",
|
||||||
|
File: "readonly",
|
||||||
|
HTMLCanvasElement: "readonly",
|
||||||
|
HTMLInputElement: "readonly",
|
||||||
|
HTMLElement: "readonly",
|
||||||
|
localStorage: "readonly",
|
||||||
|
MouseEvent: "readonly",
|
||||||
|
Touch: "readonly",
|
||||||
|
TouchEvent: "readonly",
|
||||||
|
URL: "readonly",
|
||||||
|
window: "readonly"
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeGlobals = {
|
||||||
|
console: "readonly",
|
||||||
|
process: "readonly"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"dist/**",
|
||||||
|
"node_modules/**",
|
||||||
|
"auto-imports.d.ts",
|
||||||
|
"components.d.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
...vuePlugin.configs["flat/essential"],
|
||||||
|
{
|
||||||
|
files: ["**/*.ts", "**/*.vue"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: vueParser,
|
||||||
|
parserOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
extraFileExtensions: [".vue"]
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
...browserGlobals,
|
||||||
|
...nodeGlobals,
|
||||||
|
...autoImportConfig.globals
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"@typescript-eslint": tsPlugin
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...tsPlugin.configs.recommended.rules,
|
||||||
|
camelcase: "error",
|
||||||
|
"comma-dangle": ["error", "never"],
|
||||||
|
eqeqeq: ["error", "always"],
|
||||||
|
"max-len": ["error", { code: 220 }],
|
||||||
|
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||||
|
"no-debugger": "warn",
|
||||||
|
"no-tabs": ["error", { allowIndentationTabs: true }],
|
||||||
|
quotes: ["error", "double"],
|
||||||
|
semi: ["error", "always"],
|
||||||
|
"space-before-function-paren": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
anonymous: "always",
|
||||||
|
named: "never",
|
||||||
|
asyncArrow: "always"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
"vue/no-v-html": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="application-name" content="Gao Customer H5" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Gao Customer H5" />
|
||||||
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
|
<title>Gao Customer H5</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "gao-customer-h5",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "pnpm run type-check:publish && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext .ts,.vue",
|
||||||
|
"type-check": "vue-tsc -p tsconfig.app.json --noEmit",
|
||||||
|
"type-check:publish": "vue-tsc -p tsconfig.publish.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@zxing/browser": "^0.1.5",
|
||||||
|
"@zxing/library": "^0.21.3",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"vue-echarts": "^8.0.1",
|
||||||
|
"axios": "1.15.0",
|
||||||
|
"less": "^4.3.0",
|
||||||
|
"pinia": "^3.0.2",
|
||||||
|
"tdesign-mobile-vue": "^1.16.0",
|
||||||
|
"timi-tdesign-mobile": "0.0.10",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
|
"timi-web": "0.0.24",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"vue": "^3.5.16",
|
||||||
|
"vue-router": "4.5.1",
|
||||||
|
"vue-virtual-scroller": "2.0.0-beta.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node20": "^20.1.4",
|
||||||
|
"@types/node": "^24.2.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.29.0",
|
||||||
|
"@typescript-eslint/parser": "^8.29.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
|
"@vue/compiler-sfc": "^3.5.16",
|
||||||
|
"@vue/eslint-config-typescript": "^14.5.0",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"eslint": "9.26.0",
|
||||||
|
"eslint-plugin-vue": "^9.33.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"unplugin-auto-import": "^19.1.1",
|
||||||
|
"unplugin-vue-components": "^28.4.1",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vue-eslint-parser": "^9.4.3",
|
||||||
|
"vue-tsc": "^2.2.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4181
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "Gao Customer H5",
|
||||||
|
"short_name": "Gao H5",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useGlobalUIStore } from "@/store/globalUI";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
|
||||||
|
const globalUIStore = useGlobalUIStore();
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
|
||||||
|
const resolvedThemeMode = computed(() => {
|
||||||
|
if (globalUIStore.themeMode === "system") {
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
return mediaQuery.matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
return globalUIStore.themeMode;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function unlockSoundOnUserGesture(): Promise<void> {
|
||||||
|
await settingStore.unlockSoundPlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => resolvedThemeMode.value,
|
||||||
|
(themeMode) => {
|
||||||
|
const rootElement = document.documentElement;
|
||||||
|
rootElement.classList.toggle("theme-dark", themeMode === "dark");
|
||||||
|
rootElement.dataset.theme = themeMode;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => globalUIStore.bodyBackground,
|
||||||
|
(bodyBackground) => {
|
||||||
|
document.body.style.background = bodyBackground;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
settingStore.loadSoundPlayers();
|
||||||
|
window.addEventListener("pointerdown", unlockSoundOnUserGesture, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("pointerdown", unlockSoundOnUserGesture);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { axios, type Page, type PageResult } from "timi-web";
|
||||||
|
import type { Customer, CustomerDailyStatView, CustomerGenderStatView, CustomerInviteUpdateReq, CustomerSearchReq, CustomerTrendStatReq, QuickRegisterReq } from "@/types/Customer";
|
||||||
|
import type { RegistrationEventRecord } from "@/types/RegistrationEventRecord";
|
||||||
|
|
||||||
|
const BASE_URI = "/gao/customer";
|
||||||
|
|
||||||
|
async function list(req: Page<Customer>): Promise<PageResult<Customer>> {
|
||||||
|
return await axios.post(`${BASE_URI}/list`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(req: CustomerSearchReq): Promise<Customer[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/search`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchPage(req: Page<CustomerSearchReq>): Promise<PageResult<Customer>> {
|
||||||
|
return await axios.post(`${BASE_URI}/search/page`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportExcel(req: CustomerSearchReq): Promise<Blob> {
|
||||||
|
return await axios.post(`${BASE_URI}/export`, req, {
|
||||||
|
responseType: "blob"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detail(id: string): Promise<Customer> {
|
||||||
|
return await axios.post(`${BASE_URI}/detail`, undefined, {
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detailByCode(code: string): Promise<Customer> {
|
||||||
|
return await axios.post(`${BASE_URI}/detail/code`, undefined, {
|
||||||
|
params: { code }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nullableDetailByCode(code: string): Promise<Customer | null> {
|
||||||
|
return await axios.post(`${BASE_URI}/detail/code/nullable`, undefined, {
|
||||||
|
params: { code }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invitedList(introducerCustomerId: string): Promise<Customer[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/invited/list`, undefined, {
|
||||||
|
params: { introducerCustomerId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(req: Customer): Promise<Customer> {
|
||||||
|
return await axios.post(`${BASE_URI}/create`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickRegister(req: QuickRegisterReq): Promise<RegistrationEventRecord> {
|
||||||
|
return await axios.post(`${BASE_URI}/quick/register`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(req: Customer): Promise<void> {
|
||||||
|
return await axios.post(`${BASE_URI}/update`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string): Promise<void> {
|
||||||
|
return await axios.post(`${BASE_URI}/delete`, undefined, {
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createInvite(req: CustomerInviteUpdateReq): Promise<void> {
|
||||||
|
return await axios.post(`${BASE_URI}/invite/create`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateInvite(req: CustomerInviteUpdateReq): Promise<void> {
|
||||||
|
return await axios.post(`${BASE_URI}/invite/update`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteInvite(customerId: string): Promise<void> {
|
||||||
|
return await axios.post(`${BASE_URI}/invite/delete`, undefined, {
|
||||||
|
params: { customerId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dailyStat(req: CustomerTrendStatReq): Promise<CustomerDailyStatView[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/stat/daily`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function genderStat(): Promise<CustomerGenderStatView[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/stat/gender`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
list,
|
||||||
|
search,
|
||||||
|
searchPage,
|
||||||
|
exportExcel,
|
||||||
|
detail,
|
||||||
|
detailByCode,
|
||||||
|
nullableDetailByCode,
|
||||||
|
invitedList,
|
||||||
|
create,
|
||||||
|
quickRegister,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
createInvite,
|
||||||
|
updateInvite,
|
||||||
|
deleteInvite,
|
||||||
|
dailyStat,
|
||||||
|
genderStat
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { axios, type Page, type PageResult, type Role } from "timi-web";
|
||||||
|
import type { GaoUser } from "@/types/GaoUser";
|
||||||
|
|
||||||
|
const BASE_URI = "/gao/user";
|
||||||
|
|
||||||
|
async function list(req: Page<GaoUser>): Promise<PageResult<GaoUser>> {
|
||||||
|
return await axios.post(`${BASE_URI}/list`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detail(id: string): Promise<GaoUser> {
|
||||||
|
return await axios.post(`${BASE_URI}/detail`, undefined, {
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(req: GaoUser): Promise<GaoUser> {
|
||||||
|
return await axios.post(`${BASE_URI}/create`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(req: GaoUser): Promise<GaoUser> {
|
||||||
|
return await axios.post(`${BASE_URI}/update`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string): Promise<void> {
|
||||||
|
return await axios.post(`${BASE_URI}/delete`, undefined, {
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function roleList(): Promise<Role[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/role/list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
list,
|
||||||
|
detail,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
roleList
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { axios, type Page, type PageResult } from "timi-web";
|
||||||
|
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||||
|
|
||||||
|
const BASE_URI = "/gao/registration-event";
|
||||||
|
|
||||||
|
async function list(req: Page<RegistrationEvent>): Promise<PageResult<RegistrationEvent>> {
|
||||||
|
return await axios.post(`${BASE_URI}/list`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enabledList(): Promise<RegistrationEvent[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/enabled/list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detail(id: string): Promise<RegistrationEvent> {
|
||||||
|
return await axios.post(`${BASE_URI}/detail`, undefined, {
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(req: RegistrationEvent): Promise<void> {
|
||||||
|
return await axios.post(`${BASE_URI}/create`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(req: RegistrationEvent): Promise<void> {
|
||||||
|
return await axios.post(`${BASE_URI}/update`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string): Promise<void> {
|
||||||
|
return await axios.post(`${BASE_URI}/delete`, undefined, {
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
list,
|
||||||
|
enabledList,
|
||||||
|
detail,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { axios, type Page, type PageResult } from "timi-web";
|
||||||
|
import type {
|
||||||
|
CustomerRegisterStatView,
|
||||||
|
RegistrationEventRecordCalendarView,
|
||||||
|
RegistrationEventRecord,
|
||||||
|
RegistrationEventRecordCreateReq,
|
||||||
|
RegistrationEventRecordDailyStatView,
|
||||||
|
RegistrationEventRecordListItemView,
|
||||||
|
RegistrationEventRecordRangeReq,
|
||||||
|
RegistrationEventRecordStatReq,
|
||||||
|
RegistrationEventRecordTrendStatReq
|
||||||
|
} from "@/types/RegistrationEventRecord";
|
||||||
|
|
||||||
|
const BASE_URI = "/gao/registration-event-record";
|
||||||
|
|
||||||
|
async function list(req: Page<RegistrationEventRecord>): Promise<PageResult<RegistrationEventRecord>> {
|
||||||
|
return await axios.post(`${BASE_URI}/list`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detail(id: string): Promise<RegistrationEventRecord> {
|
||||||
|
return await axios.post(`${BASE_URI}/detail`, undefined, {
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rangeList(req: RegistrationEventRecordRangeReq): Promise<RegistrationEventRecord[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/range/list`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function calendarRangeList(req: RegistrationEventRecordRangeReq): Promise<RegistrationEventRecordCalendarView[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/range/calendar/list`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rangePage(req: Page<RegistrationEventRecordRangeReq>): Promise<PageResult<RegistrationEventRecordListItemView>> {
|
||||||
|
return await axios.post(`${BASE_URI}/range/page`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rangeExport(req: RegistrationEventRecordRangeReq): Promise<Blob> {
|
||||||
|
return await axios.post(`${BASE_URI}/range/export`, req, {
|
||||||
|
responseType: "blob"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(req: RegistrationEventRecordCreateReq): Promise<RegistrationEventRecord> {
|
||||||
|
return await axios.post(`${BASE_URI}/create`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(req: RegistrationEventRecord): Promise<void> {
|
||||||
|
await axios.post(`${BASE_URI}/update`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string): Promise<void> {
|
||||||
|
await axios.post(`${BASE_URI}/delete`, undefined, {
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function customerRangeStat(req: RegistrationEventRecordStatReq): Promise<CustomerRegisterStatView[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/customer/range/stat`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function allCustomerRangeStat(req: RegistrationEventRecordStatReq): Promise<CustomerRegisterStatView[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/all-customer/range/stat`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function customerRegistrationEventDailyStat(req: RegistrationEventRecordTrendStatReq): Promise<RegistrationEventRecordDailyStatView[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/customer/registration-event/daily/stat`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function allCustomerRegistrationEventDailyStat(req: RegistrationEventRecordTrendStatReq): Promise<RegistrationEventRecordDailyStatView[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/all-customer/registration-event/daily/stat`, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
list,
|
||||||
|
detail,
|
||||||
|
rangeList,
|
||||||
|
calendarRangeList,
|
||||||
|
rangePage,
|
||||||
|
rangeExport,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
customerRangeStat,
|
||||||
|
allCustomerRangeStat,
|
||||||
|
customerRegistrationEventDailyStat,
|
||||||
|
allCustomerRegistrationEventDailyStat
|
||||||
|
};
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,313 @@
|
|||||||
|
<template>
|
||||||
|
<div class="body-selector" :class="{ readonly: props.readonly }">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 240 500"
|
||||||
|
class="figure"
|
||||||
|
role="group"
|
||||||
|
aria-label="正面人体调理部位选择"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<g class="face-guide" aria-hidden="true">
|
||||||
|
<circle cx="110" cy="56" r="3.5" />
|
||||||
|
<circle cx="130" cy="56" r="3.5" />
|
||||||
|
<path d="M120 62L116 72H124" />
|
||||||
|
<path d="M108 80C113 85 127 85 132 80" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
v-for="area in areas"
|
||||||
|
:key="area.key"
|
||||||
|
class="area"
|
||||||
|
:class="{ active: isSelected(area.key) }"
|
||||||
|
role="checkbox"
|
||||||
|
:tabindex="props.readonly ? -1 : 0"
|
||||||
|
:aria-label="area.label"
|
||||||
|
:aria-checked="isSelected(area.key)"
|
||||||
|
@click="toggle(area.key)"
|
||||||
|
@keydown.enter.prevent="toggle(area.key)"
|
||||||
|
@keydown.space.prevent="toggle(area.key)"
|
||||||
|
>
|
||||||
|
<title v-text="area.label" />
|
||||||
|
<ellipse
|
||||||
|
v-if="area.shape === 'ellipse'"
|
||||||
|
:cx="area.cx"
|
||||||
|
:cy="area.cy"
|
||||||
|
:rx="area.rx"
|
||||||
|
:ry="area.ry"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
v-else-if="area.shape === 'rect'"
|
||||||
|
:x="area.x"
|
||||||
|
:y="area.y"
|
||||||
|
:width="area.width"
|
||||||
|
:height="area.height"
|
||||||
|
:rx="area.radius"
|
||||||
|
/>
|
||||||
|
<path v-else :d="area.path" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<p class="selected" v-text="selectedText" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface BodyArea {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
shape: "ellipse" | "rect" | "path";
|
||||||
|
cx?: number;
|
||||||
|
cy?: number;
|
||||||
|
rx?: number;
|
||||||
|
ry?: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
radius?: number;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: {
|
||||||
|
selectedCodeList: string[];
|
||||||
|
};
|
||||||
|
readonly?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: { selectedCodeList: string[] }): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const areas: BodyArea[] = [
|
||||||
|
{ key: "head", label: "头部", shape: "ellipse", cx: 120, cy: 62, rx: 31, ry: 35 },
|
||||||
|
{ key: "neck", label: "颈部", shape: "rect", x: 108, y: 96, width: 24, height: 24, radius: 9 },
|
||||||
|
{ key: "left-shoulder", label: "右肩", shape: "ellipse", cx: 86, cy: 135, rx: 25, ry: 19 },
|
||||||
|
{ key: "right-shoulder", label: "左肩", shape: "ellipse", cx: 154, cy: 135, rx: 25, ry: 19 },
|
||||||
|
{
|
||||||
|
key: "left-upper-arm",
|
||||||
|
label: "右上臂",
|
||||||
|
shape: "path",
|
||||||
|
path: "M69 128C59 130 52 136 49 147L31 216C30 224 34 230 41 232C49 234 54 229 56 222L79 157C84 143 81 132 69 128Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "right-upper-arm",
|
||||||
|
label: "左上臂",
|
||||||
|
shape: "path",
|
||||||
|
path: "M171 128C181 130 188 136 191 147L209 216C210 224 206 230 199 232C191 234 186 229 184 222L161 157C156 143 159 132 171 128Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "left-forearm",
|
||||||
|
label: "右前臂",
|
||||||
|
shape: "path",
|
||||||
|
path: "M31 218C38 217 49 221 56 225L44 298C43 307 38 312 30 311C22 310 18 304 20 295Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "right-forearm",
|
||||||
|
label: "左前臂",
|
||||||
|
shape: "path",
|
||||||
|
path: "M209 218C202 217 191 221 184 225L196 298C197 307 202 312 210 311C218 310 222 304 220 295Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "left-hand",
|
||||||
|
label: "右手",
|
||||||
|
shape: "path",
|
||||||
|
path: "M20 313C22 306 40 307 44 314L42 337C40 347 34 353 28 350C23 347 19 339 18 330Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "right-hand",
|
||||||
|
label: "左手",
|
||||||
|
shape: "path",
|
||||||
|
path: "M220 313C218 306 200 307 196 314L198 337C200 347 206 353 212 350C217 347 221 339 222 330Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "left-chest",
|
||||||
|
label: "右胸",
|
||||||
|
shape: "path",
|
||||||
|
path: "M94 116C103 112 111 110 119 110L119 199C107 199 96 197 86 193L83 151Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "right-chest",
|
||||||
|
label: "左胸",
|
||||||
|
shape: "path",
|
||||||
|
path: "M121 110C129 110 137 112 146 116L157 151L154 193C144 197 133 199 121 199Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "left-abdomen",
|
||||||
|
label: "右腹",
|
||||||
|
shape: "path",
|
||||||
|
path: "M86 197C96 201 107 203 119 203L119 263C108 263 98 261 89 256Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "right-abdomen",
|
||||||
|
label: "左腹",
|
||||||
|
shape: "path",
|
||||||
|
path: "M121 203C133 203 144 201 154 197L151 256C142 261 132 263 121 263Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "left-waist",
|
||||||
|
label: "右腰",
|
||||||
|
shape: "path",
|
||||||
|
path: "M89 259C98 264 108 266 119 267L119 302C107 302 96 299 86 294Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "right-waist",
|
||||||
|
label: "左腰",
|
||||||
|
shape: "path",
|
||||||
|
path: "M121 267C132 266 142 264 151 259L154 294C144 299 133 302 121 302Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "left-thigh",
|
||||||
|
label: "右大腿",
|
||||||
|
shape: "path",
|
||||||
|
path: "M86 298C96 302 106 304 117 302L113 368C104 372 91 372 82 368L79 333Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "right-thigh",
|
||||||
|
label: "左大腿",
|
||||||
|
shape: "path",
|
||||||
|
path: "M123 302C134 304 144 302 154 298L161 333L158 368C149 372 136 372 127 368Z"
|
||||||
|
},
|
||||||
|
{ key: "left-knee", label: "右膝", shape: "ellipse", cx: 97, cy: 382, rx: 16, ry: 15 },
|
||||||
|
{ key: "right-knee", label: "左膝", shape: "ellipse", cx: 143, cy: 382, rx: 16, ry: 15 },
|
||||||
|
{
|
||||||
|
key: "left-calf",
|
||||||
|
label: "右小腿",
|
||||||
|
shape: "path",
|
||||||
|
path: "M83 397C92 400 102 400 111 397L108 462C100 466 91 466 83 462L80 424Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "right-calf",
|
||||||
|
label: "左小腿",
|
||||||
|
shape: "path",
|
||||||
|
path: "M129 397C138 400 148 400 157 397L160 424L157 462C149 466 140 466 132 462Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "left-foot",
|
||||||
|
label: "右脚",
|
||||||
|
shape: "path",
|
||||||
|
path: "M83 462C90 465 101 465 108 462L110 480C110 489 103 494 91 494H77C72 493 73 487 78 482Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "right-foot",
|
||||||
|
label: "左脚",
|
||||||
|
shape: "path",
|
||||||
|
path: "M132 462C139 465 150 465 157 462L162 482C167 487 168 493 163 494H149C137 494 130 489 130 480Z"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedText = computed(() => {
|
||||||
|
const selectedLabelList = selectedCodeList.value.map(item => areaLabelMap.value.get(item) || item);
|
||||||
|
return selectedLabelList.length ? `已选部位:${selectedLabelList.join("、")}` : "已选部位:未选择";
|
||||||
|
});
|
||||||
|
|
||||||
|
const areaLabelMap = computed(() => new Map(areas.map(item => [item.key, item.label])));
|
||||||
|
const selectedCodeList = computed(() => props.modelValue?.selectedCodeList || []);
|
||||||
|
|
||||||
|
function isSelected(code: string): boolean {
|
||||||
|
return selectedCodeList.value.includes(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(code: string): void {
|
||||||
|
if (props.readonly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Set(selectedCodeList.value);
|
||||||
|
if (next.has(code)) {
|
||||||
|
next.delete(code);
|
||||||
|
} else {
|
||||||
|
next.add(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("update:modelValue", {
|
||||||
|
selectedCodeList: Array.from(next)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.body-selector {
|
||||||
|
display: flex;
|
||||||
|
padding: .75rem;
|
||||||
|
gap: .75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f8fbff;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
|
||||||
|
.figure {
|
||||||
|
width: 100%;
|
||||||
|
height: 31rem;
|
||||||
|
max-width: 16rem;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.face-guide {
|
||||||
|
fill: none;
|
||||||
|
stroke: #94a3b8;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area {
|
||||||
|
fill: transparent;
|
||||||
|
stroke: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
pointer-events: all;
|
||||||
|
stroke-width: 2;
|
||||||
|
transition: fill .2s ease, stroke .2s ease, stroke-width .2s ease;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
stroke: #0f766e;
|
||||||
|
stroke-width: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
fill: rgba(15, 118, 110, .24);
|
||||||
|
stroke: #0f766e;
|
||||||
|
stroke-width: 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.readonly {
|
||||||
|
.area {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
margin: 0;
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.body-selector {
|
||||||
|
background: rgba(15, 23, 42, .72);
|
||||||
|
|
||||||
|
.face-guide {
|
||||||
|
stroke: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area {
|
||||||
|
fill: transparent;
|
||||||
|
stroke: #94a3b8;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
fill: rgba(94, 234, 212, .24);
|
||||||
|
stroke: #5eead4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
<template>
|
||||||
|
<div class="code-scanner">
|
||||||
|
<div class="stage">
|
||||||
|
<video ref="scannerVideoRef" class="video" autoplay muted playsinline />
|
||||||
|
<canvas ref="scannerCanvasRef" class="scanner" />
|
||||||
|
<div class="scan-frame">
|
||||||
|
<span class="scan-line" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="tips" v-text="displayStatus" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {BrowserMultiFormatReader} from "@zxing/browser";
|
||||||
|
import {BarcodeFormat, DecodeHintType, NotFoundException, type Result} from "@zxing/library";
|
||||||
|
|
||||||
|
interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
activeText?: string;
|
||||||
|
initFailedText?: string;
|
||||||
|
interruptedText?: string;
|
||||||
|
permissionDeniedText?: string;
|
||||||
|
sessionKey?: number | string;
|
||||||
|
statusText?: string;
|
||||||
|
unsupportedText?: string;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
activeText: "正在识别,请将二维码移入取景框",
|
||||||
|
initFailedText: "摄像头预览初始化失败",
|
||||||
|
interruptedText: "识别中断,请检查摄像头权限或画面清晰度",
|
||||||
|
permissionDeniedText: "无法启动摄像头,请确认已授予浏览器相机权限",
|
||||||
|
sessionKey: 0,
|
||||||
|
statusText: "",
|
||||||
|
unsupportedText: "当前浏览器或 WebView 未开放摄像头能力"
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
decode: [value: string];
|
||||||
|
"status-change": [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const scanHints = new Map<DecodeHintType, BarcodeFormat[]>();
|
||||||
|
scanHints.set(DecodeHintType.POSSIBLE_FORMATS, [
|
||||||
|
BarcodeFormat.QR_CODE,
|
||||||
|
BarcodeFormat.CODE_128,
|
||||||
|
BarcodeFormat.CODE_39,
|
||||||
|
BarcodeFormat.EAN_13,
|
||||||
|
BarcodeFormat.EAN_8
|
||||||
|
]);
|
||||||
|
|
||||||
|
const scannerReader = new BrowserMultiFormatReader(scanHints);
|
||||||
|
const scannerVideoRef = ref<HTMLVideoElement | null>(null);
|
||||||
|
const scannerCanvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const scannerControls = shallowRef<{ stop: () => void } | null>(null);
|
||||||
|
const localStatus = ref("");
|
||||||
|
const overlayPoints = ref<Point[]>([]);
|
||||||
|
let latestDecodedText = "";
|
||||||
|
let clearOverlayTimer: number | undefined;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const displayStatus = computed(() => props.statusText || localStatus.value);
|
||||||
|
|
||||||
|
function updateStatus(value: string): void {
|
||||||
|
localStatus.value = value;
|
||||||
|
emit("status-change", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOverlay(delay = 0): void {
|
||||||
|
if (clearOverlayTimer) {
|
||||||
|
window.clearTimeout(clearOverlayTimer);
|
||||||
|
clearOverlayTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delay <= 0) {
|
||||||
|
overlayPoints.value = [];
|
||||||
|
drawOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOverlayTimer = window.setTimeout(() => {
|
||||||
|
overlayPoints.value = [];
|
||||||
|
drawOverlay();
|
||||||
|
clearOverlayTimer = undefined;
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCanvasSize(): void {
|
||||||
|
if (!scannerVideoRef.value || !scannerCanvasRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = scannerVideoRef.value.clientWidth;
|
||||||
|
const height = scannerVideoRef.value.clientHeight;
|
||||||
|
if (!width || !height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scannerCanvasRef.value.width !== width || scannerCanvasRef.value.height !== height) {
|
||||||
|
scannerCanvasRef.value.width = width;
|
||||||
|
scannerCanvasRef.value.height = height;
|
||||||
|
}
|
||||||
|
drawOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayPoint(point: Point): Point {
|
||||||
|
if (!scannerVideoRef.value) {
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceWidth = scannerVideoRef.value.videoWidth || scannerVideoRef.value.clientWidth;
|
||||||
|
const sourceHeight = scannerVideoRef.value.videoHeight || scannerVideoRef.value.clientHeight;
|
||||||
|
const targetWidth = scannerVideoRef.value.clientWidth;
|
||||||
|
const targetHeight = scannerVideoRef.value.clientHeight;
|
||||||
|
const scale = Math.max(targetWidth / sourceWidth, targetHeight / sourceHeight);
|
||||||
|
const offsetX = (targetWidth - sourceWidth * scale) / 2;
|
||||||
|
const offsetY = (targetHeight - sourceHeight * scale) / 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: point.x * scale + offsetX,
|
||||||
|
y: point.y * scale + offsetY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPointsClockwise(points: Point[]): Point[] {
|
||||||
|
const center = points.reduce((result, item) => ({
|
||||||
|
x: result.x + item.x / points.length,
|
||||||
|
y: result.y + item.y / points.length
|
||||||
|
}), { x: 0, y: 0 });
|
||||||
|
|
||||||
|
return [...points].sort((left, right) => {
|
||||||
|
const leftAngle = Math.atan2(left.y - center.y, left.x - center.x);
|
||||||
|
const rightAngle = Math.atan2(right.y - center.y, right.x - center.x);
|
||||||
|
return leftAngle - rightAngle;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuadrilateralFromThreePoints(points: Point[]): Point[] {
|
||||||
|
let cornerIndex = 0;
|
||||||
|
let minDotValue = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
points.forEach((point, index) => {
|
||||||
|
const prevPoint = points[(index + 1) % points.length];
|
||||||
|
const nextPoint = points[(index + 2) % points.length];
|
||||||
|
const prevVectorX = prevPoint.x - point.x;
|
||||||
|
const prevVectorY = prevPoint.y - point.y;
|
||||||
|
const nextVectorX = nextPoint.x - point.x;
|
||||||
|
const nextVectorY = nextPoint.y - point.y;
|
||||||
|
const prevDistance = Math.hypot(prevVectorX, prevVectorY);
|
||||||
|
const nextDistance = Math.hypot(nextVectorX, nextVectorY);
|
||||||
|
if (!prevDistance || !nextDistance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotValue = Math.abs((prevVectorX * nextVectorX + prevVectorY * nextVectorY) / (prevDistance * nextDistance));
|
||||||
|
if (dotValue < minDotValue) {
|
||||||
|
minDotValue = dotValue;
|
||||||
|
cornerIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cornerPoint = points[cornerIndex];
|
||||||
|
const firstEdgePoint = points[(cornerIndex + 1) % points.length];
|
||||||
|
const secondEdgePoint = points[(cornerIndex + 2) % points.length];
|
||||||
|
const fourthPoint = {
|
||||||
|
x: firstEdgePoint.x + secondEdgePoint.x - cornerPoint.x,
|
||||||
|
y: firstEdgePoint.y + secondEdgePoint.y - cornerPoint.y
|
||||||
|
};
|
||||||
|
|
||||||
|
return sortPointsClockwise([
|
||||||
|
cornerPoint,
|
||||||
|
firstEdgePoint,
|
||||||
|
fourthPoint,
|
||||||
|
secondEdgePoint
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutlinePoints(points: Point[]): Point[] {
|
||||||
|
if (4 <= points.length) {
|
||||||
|
return sortPointsClockwise(points).slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (3 === points.length) {
|
||||||
|
return buildQuadrilateralFromThreePoints(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!points.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const xValues = points.map(item => item.x);
|
||||||
|
const yValues = points.map(item => item.y);
|
||||||
|
const minX = Math.min(...xValues);
|
||||||
|
const maxX = Math.max(...xValues);
|
||||||
|
const minY = Math.min(...yValues);
|
||||||
|
const maxY = Math.max(...yValues);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ x: minX, y: minY },
|
||||||
|
{ x: maxX, y: minY },
|
||||||
|
{ x: maxX, y: maxY },
|
||||||
|
{ x: minX, y: maxY }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandOutlinePoints(points: Point[]): Point[] {
|
||||||
|
if (!points.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = points.reduce((result, item) => ({
|
||||||
|
x: result.x + item.x / points.length,
|
||||||
|
y: result.y + item.y / points.length
|
||||||
|
}), { x: 0, y: 0 });
|
||||||
|
const width = Math.max(...points.map(item => item.x)) - Math.min(...points.map(item => item.x));
|
||||||
|
const height = Math.max(...points.map(item => item.y)) - Math.min(...points.map(item => item.y));
|
||||||
|
const padding = Math.max(Math.min(width, height) * .18, 18);
|
||||||
|
|
||||||
|
return points.map((item) => {
|
||||||
|
const offsetX = item.x - center.x;
|
||||||
|
const offsetY = item.y - center.y;
|
||||||
|
const distance = Math.hypot(offsetX, offsetY);
|
||||||
|
if (!distance) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = (distance + padding) / distance;
|
||||||
|
return {
|
||||||
|
x: center.x + offsetX * scale,
|
||||||
|
y: center.y + offsetY * scale
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOverlay(): void {
|
||||||
|
if (!scannerCanvasRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = scannerCanvasRef.value.getContext("2d");
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.clearRect(0, 0, scannerCanvasRef.value.width, scannerCanvasRef.value.height);
|
||||||
|
if (!overlayPoints.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayPoints = expandOutlinePoints(buildOutlinePoints(overlayPoints.value)).map(item => getDisplayPoint(item));
|
||||||
|
if (displayPoints.length < 4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgeLengths = displayPoints.map((item, index) => {
|
||||||
|
const nextPoint = displayPoints[(index + 1) % displayPoints.length];
|
||||||
|
return Math.hypot(nextPoint.x - item.x, nextPoint.y - item.y);
|
||||||
|
});
|
||||||
|
const width = Math.max(edgeLengths[0] || 0, edgeLengths[2] || 0);
|
||||||
|
const height = Math.max(edgeLengths[1] || 0, edgeLengths[3] || 0);
|
||||||
|
const cornerLength = Math.max(14, Math.min(Math.min(width, height) * .2, 28));
|
||||||
|
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.lineWidth = 3;
|
||||||
|
context.strokeStyle = "#22c55e";
|
||||||
|
|
||||||
|
const drawCorner = (point: Point, prevPoint: Point, nextPoint: Point): void => {
|
||||||
|
const prevOffsetX = prevPoint.x - point.x;
|
||||||
|
const prevOffsetY = prevPoint.y - point.y;
|
||||||
|
const nextOffsetX = nextPoint.x - point.x;
|
||||||
|
const nextOffsetY = nextPoint.y - point.y;
|
||||||
|
const prevDistance = Math.hypot(prevOffsetX, prevOffsetY);
|
||||||
|
const nextDistance = Math.hypot(nextOffsetX, nextOffsetY);
|
||||||
|
if (!prevDistance || !nextDistance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevScale = cornerLength / prevDistance;
|
||||||
|
const nextScale = cornerLength / nextDistance;
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(point.x + prevOffsetX * prevScale, point.y + prevOffsetY * prevScale);
|
||||||
|
context.lineTo(point.x, point.y);
|
||||||
|
context.lineTo(point.x + nextOffsetX * nextScale, point.y + nextOffsetY * nextScale);
|
||||||
|
context.stroke();
|
||||||
|
};
|
||||||
|
|
||||||
|
displayPoints.forEach((point, index) => {
|
||||||
|
const prevPoint = displayPoints[(index - 1 + displayPoints.length) % displayPoints.length];
|
||||||
|
const nextPoint = displayPoints[(index + 1) % displayPoints.length];
|
||||||
|
drawCorner(point, prevPoint, nextPoint);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOverlay(result: Result): void {
|
||||||
|
overlayPoints.value = result.getResultPoints()
|
||||||
|
.map(item => ({
|
||||||
|
x: item.getX(),
|
||||||
|
y: item.getY()
|
||||||
|
}))
|
||||||
|
.filter(item => Number.isFinite(item.x) && Number.isFinite(item.y));
|
||||||
|
drawOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScanner(): Promise<void> {
|
||||||
|
if (!props.visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
updateStatus(window.isSecureContext ? props.unsupportedText : "当前页面不是 HTTPS 安全环境,无法调用摄像头");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopScanner();
|
||||||
|
latestDecodedText = "";
|
||||||
|
updateStatus(props.activeText);
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
if (!scannerVideoRef.value) {
|
||||||
|
updateStatus(props.initFailedText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncCanvasSize();
|
||||||
|
try {
|
||||||
|
scannerControls.value = await scannerReader.decodeFromConstraints({
|
||||||
|
audio: false,
|
||||||
|
video: {
|
||||||
|
facingMode: {
|
||||||
|
ideal: "environment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, scannerVideoRef.value, (result, error) => {
|
||||||
|
if (result) {
|
||||||
|
updateOverlay(result);
|
||||||
|
clearOverlay(900);
|
||||||
|
const decodedText = result.getText().trim();
|
||||||
|
if (!decodedText || decodedText === latestDecodedText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
latestDecodedText = decodedText;
|
||||||
|
emit("decode", decodedText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof NotFoundException) {
|
||||||
|
clearOverlay(120);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
updateStatus(props.interruptedText);
|
||||||
|
clearOverlay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
updateStatus(props.permissionDeniedText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScanner(): void {
|
||||||
|
scannerControls.value?.stop();
|
||||||
|
scannerControls.value = null;
|
||||||
|
if (scannerVideoRef.value?.srcObject instanceof MediaStream) {
|
||||||
|
for (const track of scannerVideoRef.value.srcObject.getTracks()) {
|
||||||
|
track.stop();
|
||||||
|
}
|
||||||
|
scannerVideoRef.value.srcObject = null;
|
||||||
|
}
|
||||||
|
clearOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.visible, props.sessionKey] as const, async ([visible]) => {
|
||||||
|
if (!visible) {
|
||||||
|
stopScanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await startScanner();
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof ResizeObserver === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
syncCanvasSize();
|
||||||
|
});
|
||||||
|
if (scannerVideoRef.value) {
|
||||||
|
resizeObserver.observe(scannerVideoRef.value);
|
||||||
|
scannerVideoRef.value.addEventListener("loadedmetadata", syncCanvasSize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (scannerVideoRef.value) {
|
||||||
|
scannerVideoRef.value.removeEventListener("loadedmetadata", syncCanvasSize);
|
||||||
|
}
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
if (clearOverlayTimer) {
|
||||||
|
window.clearTimeout(clearOverlayTimer);
|
||||||
|
}
|
||||||
|
stopScanner();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.code-scanner {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 30rem;
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #0f172a;
|
||||||
|
|
||||||
|
.video,
|
||||||
|
.scanner,
|
||||||
|
.scan-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-frame {
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
max-width: 18rem;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
.scan-line {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
height: .1875rem;
|
||||||
|
position: absolute;
|
||||||
|
background: linear-gradient(90deg, rgba(34, 197, 94, 0), rgba(74, 222, 128, .95), rgba(34, 197, 94, 0));
|
||||||
|
animation: scan-line-down 3s cubic-bezier(0, .5, 1, .5) infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
color: #52607a;
|
||||||
|
margin: .625rem 0 0;
|
||||||
|
font-size: .8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan-line-down {
|
||||||
|
0% {
|
||||||
|
top: 8%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
15% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
85% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
top: 92%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.code-scanner {
|
||||||
|
background: rgba(30, 41, 59, .9);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-frame {
|
||||||
|
.corner {
|
||||||
|
border-color: #4ade80;
|
||||||
|
filter: drop-shadow(0 0 .5rem rgba(74, 222, 128, .45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-line {
|
||||||
|
background: linear-gradient(90deg, rgba(74, 222, 128, 0), rgba(134, 239, 172, .95), rgba(74, 222, 128, 0));
|
||||||
|
box-shadow: 0 0 .875rem rgba(74, 222, 128, .8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="customer-profile-card">
|
||||||
|
<t-button v-if="photoURL" variant="text" class="avatar-trigger" @click="openPhotoViewer">
|
||||||
|
<t-image :src="photoURL" :alt="customerName" fit="cover" class="avatar" />
|
||||||
|
</t-button>
|
||||||
|
<div v-else class="avatar fallback" v-text="avatarText" />
|
||||||
|
<div class="content">
|
||||||
|
<div class="header">
|
||||||
|
<div class="name">
|
||||||
|
<span v-text="customerName"></span>
|
||||||
|
<gender-icon v-if="customer?.gender" :value="customer.gender" size="1rem" />
|
||||||
|
</div>
|
||||||
|
<span class="gray" v-text="customerCode" />
|
||||||
|
</div>
|
||||||
|
<span class="telephone gray" v-text="customerTelephone" />
|
||||||
|
</div>
|
||||||
|
<t-image-viewer
|
||||||
|
v-model:visible="photoViewerVisible"
|
||||||
|
:images="photoViewerImages"
|
||||||
|
show-index
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Customer } from "@/types/Customer";
|
||||||
|
import { getCustomerPhotoURL } from "@/utils/customer";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "CustomerProfileCard"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
customer?: Customer;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const photoViewerVisible = ref(false);
|
||||||
|
const customer = computed(() => props.customer);
|
||||||
|
const customerName = computed(() => customer.value?.name || customer.value?.code || "");
|
||||||
|
const avatarText = computed(() => customerName.value.slice(0, 1));
|
||||||
|
const customerCode = computed(() => customer.value?.code || "");
|
||||||
|
const customerTelephone = computed(() => customer.value?.telephone || "");
|
||||||
|
const photoURL = computed(() => customer.value ? getCustomerPhotoURL(customer.value) : "");
|
||||||
|
const photoViewerImages = computed(() => photoURL.value ? [photoURL.value] : []);
|
||||||
|
|
||||||
|
function openPhotoViewer(): void {
|
||||||
|
if (!photoURL.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
photoViewerVisible.value = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.customer-profile-card {
|
||||||
|
gap: .875rem;
|
||||||
|
padding: .5rem;
|
||||||
|
display: flex;
|
||||||
|
background: #e6eeff;
|
||||||
|
border-radius: 1.125rem;
|
||||||
|
|
||||||
|
.avatar-trigger {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
display: block;
|
||||||
|
border-radius: .875rem;
|
||||||
|
|
||||||
|
&.fallback {
|
||||||
|
color: #2563eb;
|
||||||
|
display: flex;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
background: #dbeafe;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-right: .5rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.telephone {
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.customer-profile-card {
|
||||||
|
background: rgba(30, 41, 59, .9);
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line h3 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<template>
|
||||||
|
<t-popup v-model="popupVisible" placement="bottom">
|
||||||
|
<div class="customer-selector bg-white">
|
||||||
|
<h3 class="title">选择客户</h3>
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<t-input
|
||||||
|
v-model.trim="keyword"
|
||||||
|
class="search"
|
||||||
|
borderless
|
||||||
|
clearable
|
||||||
|
placeholder="姓名 / 编码 / 手机号"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="customerOptions.length" ref="resultListRef" class="result-list">
|
||||||
|
<t-cell-group>
|
||||||
|
<t-cell
|
||||||
|
v-for="(item, index) in customerOptions"
|
||||||
|
:key="item.id || item.code || index"
|
||||||
|
:description="item.telephone || '未填写电话'"
|
||||||
|
:note="item.code"
|
||||||
|
hover
|
||||||
|
@click="selectCustomer(item)"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="cell-title">
|
||||||
|
<span v-text="item.name || item.code || '未命名客户'" />
|
||||||
|
<gender-icon :value="item.gender" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-image v-if="getCustomerPhotoURL(item)" :src="getCustomerPhotoURL(item)" :alt="item.name" fit="cover" class="avatar" />
|
||||||
|
<div v-else class="avatar fallback" v-text="(item.name || item.code || '客').slice(0, 1)" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<div class="load-state" v-text="loadStateText" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-block" v-text="loading ? '正在加载客户' : '未找到匹配客户'" />
|
||||||
|
</div>
|
||||||
|
</t-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Scroller } from "timi-web";
|
||||||
|
import CustomerAPI from "@/api/CustomerAPI";
|
||||||
|
import type { Customer } from "@/types/Customer";
|
||||||
|
import { getCustomerPhotoURL } from "@/utils/customer";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
visible: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: string];
|
||||||
|
"update:visible": [value: boolean];
|
||||||
|
change: [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
const scrollListenerName = "CustomerSelector";
|
||||||
|
const keyword = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const noMore = ref(false);
|
||||||
|
const pageIndex = ref(0);
|
||||||
|
const totalCount = ref(0);
|
||||||
|
const customerOptions = ref<Customer[]>([]);
|
||||||
|
const resultListRef = ref<HTMLElement>();
|
||||||
|
let searchToken = 0;
|
||||||
|
let keywordTimer: number | undefined;
|
||||||
|
|
||||||
|
const loadStateText = computed(() => {
|
||||||
|
if (loading.value) {
|
||||||
|
return "正在加载更多";
|
||||||
|
}
|
||||||
|
|
||||||
|
return noMore.value ? `共 ${totalCount.value} 位客户` : "上拉加载更多";
|
||||||
|
});
|
||||||
|
|
||||||
|
const popupVisible = computed({
|
||||||
|
get() {
|
||||||
|
return props.visible;
|
||||||
|
},
|
||||||
|
set(value: boolean) {
|
||||||
|
emit("update:visible", value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function reloadCustomers(): Promise<void> {
|
||||||
|
pageIndex.value = 0;
|
||||||
|
totalCount.value = 0;
|
||||||
|
customerOptions.value = [];
|
||||||
|
noMore.value = false;
|
||||||
|
await loadCustomers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomers(): Promise<void> {
|
||||||
|
if (loading.value || noMore.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = searchToken + 1;
|
||||||
|
searchToken = token;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await CustomerAPI.searchPage({
|
||||||
|
index: pageIndex.value,
|
||||||
|
size: pageSize,
|
||||||
|
equalsExample: {
|
||||||
|
keyword: keyword.value || undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (token !== searchToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const list = result.list || [];
|
||||||
|
customerOptions.value = customerOptions.value.concat(list);
|
||||||
|
totalCount.value = result.total || 0;
|
||||||
|
pageIndex.value += 1;
|
||||||
|
noMore.value = totalCount.value <= customerOptions.value.length || list.length < pageSize;
|
||||||
|
} finally {
|
||||||
|
if (token === searchToken) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindScrollListener(): Promise<void> {
|
||||||
|
if (!props.visible || customerOptions.value.length <= 0) {
|
||||||
|
Scroller.removeListener(scrollListenerName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
Scroller.addBottomListener(scrollListenerName, loadCustomers, {
|
||||||
|
distance: 48,
|
||||||
|
canLoad: () => props.visible && !loading.value && !noMore.value,
|
||||||
|
getElement: () => resultListRef.value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCustomer(item: Customer): void {
|
||||||
|
const id = item.id;
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("update:modelValue", id);
|
||||||
|
emit("change", id);
|
||||||
|
emit("update:visible", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.visible, async (value) => {
|
||||||
|
if (!value) {
|
||||||
|
keyword.value = "";
|
||||||
|
window.clearTimeout(keywordTimer);
|
||||||
|
Scroller.removeListener(scrollListenerName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await reloadCustomers();
|
||||||
|
await bindScrollListener();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(keyword, async () => {
|
||||||
|
if (!props.visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.clearTimeout(keywordTimer);
|
||||||
|
keywordTimer = window.setTimeout(async () => {
|
||||||
|
await reloadCustomers();
|
||||||
|
await bindScrollListener();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => customerOptions.value.length, async () => {
|
||||||
|
await bindScrollListener();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.clearTimeout(keywordTimer);
|
||||||
|
Scroller.removeListener(scrollListenerName);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.customer-selector {
|
||||||
|
display: flex;
|
||||||
|
height: 70vh;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-radius: 1.25rem 1.25rem 0 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
z-index: 1;
|
||||||
|
padding: 1rem 0 .5rem 0;
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
|
||||||
|
.search {
|
||||||
|
--td-input-vertical-padding: 8px;
|
||||||
|
|
||||||
|
flex: 0;
|
||||||
|
margin: 0 1rem;
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
border-radius: .875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
|
||||||
|
.cell-title {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-state {
|
||||||
|
padding: .75rem;
|
||||||
|
color: #52607a;
|
||||||
|
text-align: center;
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
|
||||||
|
&.fallback {
|
||||||
|
color: #2563eb;
|
||||||
|
display: flex;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
background: #dbeafe;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.avatar .t-image__img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
color: #52607a;
|
||||||
|
margin: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8fbff;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.customer-selector {
|
||||||
|
background: rgba(15, 23, 42, .96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(30, 41, 59, .9);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-state {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
|
||||||
|
&.fallback {
|
||||||
|
color: #93c5fd;
|
||||||
|
background: rgba(30, 64, 175, .32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<t-icon
|
||||||
|
v-if="iconName"
|
||||||
|
:name="iconName"
|
||||||
|
:size="size"
|
||||||
|
:class="['gender-icon', `gender-icon--${tone}`]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
size?: string;
|
||||||
|
value?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
size: "1rem",
|
||||||
|
value: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedValue = computed(() => (props.value || "").trim());
|
||||||
|
const iconName = computed(() => {
|
||||||
|
if (normalizedValue.value === "MALE" || normalizedValue.value === "男") {
|
||||||
|
return "gender-male";
|
||||||
|
}
|
||||||
|
if (normalizedValue.value === "FEMALE" || normalizedValue.value === "女") {
|
||||||
|
return "gender-female";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
const tone = computed(() => normalizedValue.value === "FEMALE" || normalizedValue.value === "女" ? "female" : "male");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.gender-icon {
|
||||||
|
flex: none;
|
||||||
|
|
||||||
|
&--male {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--female {
|
||||||
|
color: #db2777;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.gender-icon {
|
||||||
|
&--male {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--female {
|
||||||
|
color: #f9a8d4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-transition">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition :name="transitionName">
|
||||||
|
<div :key="route.fullPath" class="pages">
|
||||||
|
<component :is="Component" />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalized } from "vue-router";
|
||||||
|
import { useGlobalUIStore } from "@/store/globalUI";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "PageTransition"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const globalUIStore = useGlobalUIStore();
|
||||||
|
|
||||||
|
const transitionName = ref("push-left");
|
||||||
|
const pageBackground = computed(() => globalUIStore.bodyBackground);
|
||||||
|
|
||||||
|
// ---------- 路由深度计算 ----------
|
||||||
|
|
||||||
|
const pathCache = new Map<string, number>();
|
||||||
|
|
||||||
|
function calcDepth(sourceRoute: RouteLocationNormalized): number {
|
||||||
|
if (!sourceRoute.meta.dynamicDepth) {
|
||||||
|
return Number(sourceRoute.meta.depth ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = sourceRoute.fullPath;
|
||||||
|
const cachedDepth = pathCache.get(fullPath);
|
||||||
|
if (cachedDepth !== undefined) {
|
||||||
|
return cachedDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDepth = Number(sourceRoute.meta.baseDepth ?? 0);
|
||||||
|
const pathSegments = fullPath.split("?")[0].split("/").filter((pathSegment) => pathSegment !== "");
|
||||||
|
const depth = baseDepth + (pathSegments.length - 1);
|
||||||
|
pathCache.set(fullPath, depth);
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unregisterGuard = router.beforeEach((to, from) => {
|
||||||
|
const toDepth = calcDepth(to);
|
||||||
|
const fromDepth = calcDepth(from);
|
||||||
|
|
||||||
|
if (fromDepth < toDepth) {
|
||||||
|
transitionName.value = "push-left";
|
||||||
|
} else if (toDepth < fromDepth) {
|
||||||
|
transitionName.value = "push-right";
|
||||||
|
} else {
|
||||||
|
transitionName.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unregisterGuard();
|
||||||
|
pathCache.clear();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
@easing: cubic-bezier(.19, 1, .22, 1);
|
||||||
|
@duration: 520ms;
|
||||||
|
@brightness: .98;
|
||||||
|
@shadow-intensity: .15;
|
||||||
|
@page-offset: 25%;
|
||||||
|
|
||||||
|
.page-transition {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
// 此 wrapper 必须独立,不可将 class=pages 直接加在 <component> 上:
|
||||||
|
// 否则页面 root element 与动画容器同为一个 DOM 节点,
|
||||||
|
// 页面自身的 padding 会使 position:fixed; width:100% 的盒子溢出视口。
|
||||||
|
.pages {
|
||||||
|
height: 100%;
|
||||||
|
background: v-bind(pageBackground);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- push-left / push-right 公共激活状态 ----------
|
||||||
|
|
||||||
|
.push-left-enter-active,
|
||||||
|
.push-left-leave-active,
|
||||||
|
.push-right-enter-active,
|
||||||
|
.push-right-leave-active {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: fixed;
|
||||||
|
transition: transform @duration @easing, opacity .2s linear;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 前进:下一页从右滑入,当前页缩退到左侧 ----------
|
||||||
|
|
||||||
|
.push-left-enter-from {
|
||||||
|
transform: translate3d(100%, 0, 0);
|
||||||
|
box-shadow: -4px 0 16px rgba(0, 0, 0, @shadow-intensity);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-left-enter-to {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-left-leave-active {
|
||||||
|
transition: all @duration @easing, filter .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-left-leave-to {
|
||||||
|
filter: brightness(@brightness);
|
||||||
|
transform: translate3d(-@page-offset, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 返回:当前页从右滑出,上一页从左侧复位 ----------
|
||||||
|
|
||||||
|
.push-right-enter-active {
|
||||||
|
z-index: 0;
|
||||||
|
transition: transform @duration @easing, opacity .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-right-enter-from {
|
||||||
|
transform: translate3d(-@page-offset, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-right-enter-to {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-right-leave-active {
|
||||||
|
z-index: 1;
|
||||||
|
transition: transform @duration @easing, box-shadow .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-right-leave-from {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-right-leave-to {
|
||||||
|
transform: translate3d(105%, 0, 0);
|
||||||
|
box-shadow: 4px 0 16px rgba(0, 0, 0, @shadow-intensity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<div class="photo-field" :class="{ compact: size === 'compact' }">
|
||||||
|
<t-button
|
||||||
|
v-if="!modelValue"
|
||||||
|
theme="primary"
|
||||||
|
variant="dashed"
|
||||||
|
class="add"
|
||||||
|
@click="$emit('add')"
|
||||||
|
>
|
||||||
|
<span v-text="addText" />
|
||||||
|
</t-button>
|
||||||
|
<div v-else class="card">
|
||||||
|
<t-button variant="text" class="preview-trigger" @click="handlePhotoClick">
|
||||||
|
<t-image
|
||||||
|
class="image"
|
||||||
|
:src="modelValue"
|
||||||
|
:alt="altText"
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
v-if="showRemove"
|
||||||
|
class="remove"
|
||||||
|
theme="danger"
|
||||||
|
shape="circle"
|
||||||
|
size="extra-small"
|
||||||
|
@click="clearPhoto"
|
||||||
|
>
|
||||||
|
<t-icon name="close" size="1rem" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<t-image-viewer
|
||||||
|
v-model:visible="photoViewerVisible"
|
||||||
|
:images="photoViewerImages"
|
||||||
|
show-index
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
addText?: string;
|
||||||
|
altText?: string;
|
||||||
|
modelValue: string;
|
||||||
|
preview?: boolean;
|
||||||
|
showRemove?: boolean;
|
||||||
|
size?: "normal" | "compact";
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
addText: "拍照",
|
||||||
|
altText: "照片",
|
||||||
|
preview: true,
|
||||||
|
showRemove: true,
|
||||||
|
size: "normal"
|
||||||
|
});
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "PhotoField"
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
add: [];
|
||||||
|
clear: [];
|
||||||
|
"update:modelValue": [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const photoViewerVisible = ref(false);
|
||||||
|
const photoViewerImages = computed(() => props.modelValue ? [props.modelValue] : []);
|
||||||
|
|
||||||
|
function handlePhotoClick(): void {
|
||||||
|
if (!props.preview) {
|
||||||
|
emit("add");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
photoViewerVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPhoto(): void {
|
||||||
|
emit("update:modelValue", "");
|
||||||
|
emit("clear");
|
||||||
|
photoViewerVisible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.photo-field {
|
||||||
|
gap: .75rem;
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
.add {
|
||||||
|
--td-button-border-radius: .875rem;
|
||||||
|
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-trigger {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
border-radius: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
top: .375rem;
|
||||||
|
right: .375rem;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
:deep(.t-button__content) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
.add,
|
||||||
|
.card {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<template>
|
||||||
|
<div class="photo-section">
|
||||||
|
<div class="t-form__label" v-text="title" />
|
||||||
|
<div class="photo-shell" :class="{ 'without-add': !showAdd }">
|
||||||
|
<div class="photo-row">
|
||||||
|
<div v-if="!displayPhotoList.length" class="photo-empty" v-text="emptyText" />
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in displayPhotoList"
|
||||||
|
:key="`${item}-${index}`"
|
||||||
|
class="photo-item"
|
||||||
|
>
|
||||||
|
<t-button variant="text" class="photo-preview-trigger" @click="openPhotoViewer(index)">
|
||||||
|
<t-image :src="item" alt="登记照片" fit="cover" class="photo-image" />
|
||||||
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
v-if="showRemove && modelValue.length"
|
||||||
|
theme="danger"
|
||||||
|
class="remove"
|
||||||
|
shape="circle"
|
||||||
|
size="extra-small"
|
||||||
|
@click="removePhoto(index)"
|
||||||
|
>
|
||||||
|
<t-icon name="close" size="1rem" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<t-button
|
||||||
|
v-if="showAdd"
|
||||||
|
theme="primary"
|
||||||
|
variant="dashed"
|
||||||
|
class="add"
|
||||||
|
@click="$emit('add')"
|
||||||
|
>
|
||||||
|
<span v-text="addText" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<t-image-viewer
|
||||||
|
v-model:visible="photoViewerVisible"
|
||||||
|
:images="photoViewerImages"
|
||||||
|
show-index
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
addText?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
fallbackImage?: string;
|
||||||
|
modelValue: string[];
|
||||||
|
showAdd?: boolean;
|
||||||
|
showRemove?: boolean;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
title: "照片列表",
|
||||||
|
addText: "拍照",
|
||||||
|
emptyText: "暂无照片",
|
||||||
|
showAdd: true,
|
||||||
|
showRemove: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
add: [];
|
||||||
|
"update:modelValue": [value: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const photoViewerVisible = ref(false);
|
||||||
|
const photoViewerImages = ref<string[]>([]);
|
||||||
|
const displayPhotoList = computed(() => {
|
||||||
|
if (props.modelValue.length) {
|
||||||
|
return props.modelValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.fallbackImage ? [props.fallbackImage] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function removePhoto(index: number): void {
|
||||||
|
if (index < 0 || props.modelValue.length <= index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValue = props.modelValue.filter((_, itemIndex) => itemIndex !== index);
|
||||||
|
emit("update:modelValue", nextValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPhotoViewer(index: number): void {
|
||||||
|
photoViewerImages.value = [...displayPhotoList.value.slice(index), ...displayPhotoList.value.slice(0, index)];
|
||||||
|
photoViewerVisible.value = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.photo-section {
|
||||||
|
display: grid;
|
||||||
|
gap: .375rem;
|
||||||
|
|
||||||
|
.photo-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: .75rem;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 5.5rem;
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
|
&.without-add {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-row {
|
||||||
|
gap: .75rem;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
--td-button-border-radius: .875rem;
|
||||||
|
|
||||||
|
flex: 0 0 5.5rem;
|
||||||
|
width: 5.5rem;
|
||||||
|
height: 5.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-item {
|
||||||
|
width: 5.5rem;
|
||||||
|
height: 5.5rem;
|
||||||
|
flex: 0 0 5.5rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview-trigger {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
top: .375rem;
|
||||||
|
right: .375rem;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
:deep(.t-button__content) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-empty {
|
||||||
|
width: 100%;
|
||||||
|
color: #52607a;
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f8fbff;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.photo-section {
|
||||||
|
.field-title,
|
||||||
|
.photo-empty {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-empty {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, .72);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
background: rgba(37, 99, 235, .16);
|
||||||
|
border-color: rgba(96, 165, 250, .28);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
<template>
|
||||||
|
<section class="register-record-calendar">
|
||||||
|
<div class="calendar-head">
|
||||||
|
<t-button class="month-btn" shape="square" variant="text" @click="shiftMonth(-1)">
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="chevron-left" />
|
||||||
|
</template>
|
||||||
|
</t-button>
|
||||||
|
<h2 v-text="monthTitle" />
|
||||||
|
<div class="head-actions">
|
||||||
|
<t-button class="month-btn" shape="square" variant="text" @click="shiftMonth(1)">
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="chevron-right" />
|
||||||
|
</template>
|
||||||
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
v-if="showRegistrationEventFilter"
|
||||||
|
class="filter-btn"
|
||||||
|
theme="primary"
|
||||||
|
size="extra-small"
|
||||||
|
variant="outline"
|
||||||
|
@click="registrationEventFilterVisible = true"
|
||||||
|
content="登记事件"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="filter" />
|
||||||
|
</template>
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="week-line">
|
||||||
|
<span v-for="item in weekNameList" :key="item" v-text="item" />
|
||||||
|
</div>
|
||||||
|
<div class="calendar-grid">
|
||||||
|
<button
|
||||||
|
v-for="item in calendarDayList"
|
||||||
|
:key="item.key"
|
||||||
|
class="day-cell"
|
||||||
|
:class="{muted: !item.inMonth, today: item.isToday, marked: 0 < item.records.length}"
|
||||||
|
type="button"
|
||||||
|
:disabled="!item.records.length"
|
||||||
|
@click="openDayRecords(item)"
|
||||||
|
>
|
||||||
|
<span class="date" v-text="item.day" />
|
||||||
|
<span v-if="item.records.length" class="count" v-text="`${item.records.length} 条`" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="empty-block">正在加载登记日历</div>
|
||||||
|
<div v-else-if="!currentMonthFilteredRecords.length" class="empty-block" v-text="emptyText" />
|
||||||
|
<t-popup v-model="dayRecordVisible" placement="bottom">
|
||||||
|
<div class="day-record-panel">
|
||||||
|
<h3 v-text="selectedDayTitle" />
|
||||||
|
<t-cell-group v-if="selectedDayRecords.length" theme="card">
|
||||||
|
<t-cell
|
||||||
|
v-for="item in selectedDayRecords"
|
||||||
|
:key="item.id"
|
||||||
|
:title="getRecordRegistrationEventName(item)"
|
||||||
|
:description="getRecordCustomerName(item)"
|
||||||
|
:note="`${formatRecordTime(item.registeredAt)}`"
|
||||||
|
/>
|
||||||
|
</t-cell-group>
|
||||||
|
<div v-else class="empty-block">该日暂无登记事件记录</div>
|
||||||
|
</div>
|
||||||
|
</t-popup>
|
||||||
|
<t-popup v-model="registrationEventFilterVisible" placement="bottom">
|
||||||
|
<div class="filter-popup">
|
||||||
|
<div class="title">登记事件筛选</div>
|
||||||
|
<t-checkbox-group v-model="selectedRegistrationEventIdListProxy" class="items">
|
||||||
|
<t-checkbox
|
||||||
|
class="item"
|
||||||
|
v-for="item in registrationEventList"
|
||||||
|
:key="item.id"
|
||||||
|
:value="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
block
|
||||||
|
borderless
|
||||||
|
/>
|
||||||
|
</t-checkbox-group>
|
||||||
|
</div>
|
||||||
|
</t-popup>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {RegistrationEvent} from "@/types/RegistrationEvent";
|
||||||
|
import type {RegistrationEventRecord} from "@/types/RegistrationEventRecord";
|
||||||
|
import {Time} from "timi-web";
|
||||||
|
|
||||||
|
interface CalendarDay {
|
||||||
|
key: string;
|
||||||
|
day: string;
|
||||||
|
dateText: string;
|
||||||
|
inMonth: boolean;
|
||||||
|
isToday: boolean;
|
||||||
|
records: RegistrationEventRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
registrationEventList: RegistrationEvent[];
|
||||||
|
customerName?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
modelValue: Date;
|
||||||
|
records: RegistrationEventRecord[];
|
||||||
|
selectedRegistrationEventIdList: string[];
|
||||||
|
showRegistrationEventFilter?: boolean;
|
||||||
|
}>(), {
|
||||||
|
customerName: "",
|
||||||
|
emptyText: "当前月份暂无匹配登记",
|
||||||
|
loading: false,
|
||||||
|
showRegistrationEventFilter: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: Date];
|
||||||
|
"update:selectedRegistrationEventIdList": [value: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const weekNameList = ["日", "一", "二", "三", "四", "五", "六"];
|
||||||
|
const dayRecordVisible = ref(false);
|
||||||
|
const registrationEventFilterVisible = ref(false);
|
||||||
|
const selectedDayTitle = ref("");
|
||||||
|
const selectedDayRecords = ref<RegistrationEventRecord[]>([]);
|
||||||
|
|
||||||
|
const selectedRegistrationEventIdListProxy = computed({
|
||||||
|
get: () => props.selectedRegistrationEventIdList,
|
||||||
|
set: value => emit("update:selectedRegistrationEventIdList", value)
|
||||||
|
});
|
||||||
|
const registrationEventNameMap = computed(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const item of props.registrationEventList) {
|
||||||
|
if (item.id) {
|
||||||
|
map.set(item.id, item.name || "未命名事件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const selectedRegistrationEventIdSet = computed(() => new Set(props.selectedRegistrationEventIdList));
|
||||||
|
const filteredRecords = computed(() => {
|
||||||
|
if (!props.showRegistrationEventFilter || !props.selectedRegistrationEventIdList.length) {
|
||||||
|
return props.records;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.records.filter(item => item.registrationEventId && selectedRegistrationEventIdSet.value.has(item.registrationEventId));
|
||||||
|
});
|
||||||
|
const currentMonthFilteredRecords = computed(() => filteredRecords.value.filter(item => isInCurrentMonth(item.registeredAt)));
|
||||||
|
const monthTitle = computed(() => `${props.modelValue.getFullYear()} 年 ${props.modelValue.getMonth() + 1} 月`);
|
||||||
|
const calendarDayList = computed<CalendarDay[]>(() => {
|
||||||
|
const beginDate = getCalendarBeginDate(props.modelValue);
|
||||||
|
const todayText = Time.formatDate(new Date());
|
||||||
|
const list: CalendarDay[] = [];
|
||||||
|
for (let index = 0; index < 42; index += 1) {
|
||||||
|
const date = addDays(beginDate, index);
|
||||||
|
const dateText = Time.formatDate(date);
|
||||||
|
list.push({
|
||||||
|
key: dateText,
|
||||||
|
day: String(date.getDate()),
|
||||||
|
dateText,
|
||||||
|
inMonth: date.getMonth() === props.modelValue.getMonth(),
|
||||||
|
isToday: dateText === todayText,
|
||||||
|
records: filteredRecords.value.filter(item => getRecordDateText(item) === dateText)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
function shiftMonth(offset: number): void {
|
||||||
|
emit("update:modelValue", addMonths(props.modelValue, offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDayRecords(day: CalendarDay): void {
|
||||||
|
if (!day.records.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedDayTitle.value = `${day.dateText} 登记事件记录`;
|
||||||
|
selectedDayRecords.value = [...day.records].sort((left, right) => (right.registeredAt || 0) - (left.registeredAt || 0));
|
||||||
|
dayRecordVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordRegistrationEventName(record?: RegistrationEventRecord): string {
|
||||||
|
if (!record) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return record.registrationEvent?.name || (record.registrationEventId ? registrationEventNameMap.value.get(record.registrationEventId) : "") || "未知事件";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordCustomerName(record: RegistrationEventRecord): string {
|
||||||
|
return record.customer?.name || props.customerName || record.customerId || "未知客户";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecordTime(value?: number): string {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = Time.toDateObject(value);
|
||||||
|
if (!date) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return Time.formatDateTime(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordDateText(record: RegistrationEventRecord): string {
|
||||||
|
const date = Time.toDateObject(record.registeredAt);
|
||||||
|
return date ? Time.formatDate(date) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInCurrentMonth(value?: number): boolean {
|
||||||
|
const date = Time.toDateObject(value);
|
||||||
|
return !!date && date.getFullYear() === props.modelValue.getFullYear() && date.getMonth() === props.modelValue.getMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthStart(date: Date): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarBeginDate(date: Date): Date {
|
||||||
|
const monthStart = getMonthStart(date);
|
||||||
|
return addDays(monthStart, -monthStart.getDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number): Date {
|
||||||
|
const value = new Date(date);
|
||||||
|
value.setDate(value.getDate() + days);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(date: Date, months: number): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth() + months, 1, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.register-record-calendar {
|
||||||
|
gap: .875rem;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: .75rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.calendar-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-btn {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-actions {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-line,
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-line {
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
gap: .375rem;
|
||||||
|
|
||||||
|
.day-cell {
|
||||||
|
padding: .25rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
border: 1px solid var(--td-component-border);
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-radius: .25rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
width: 100%;
|
||||||
|
color: #2563eb;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: .625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.today {
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.marked {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
padding: 1rem;
|
||||||
|
color: #52607a;
|
||||||
|
text-align: center;
|
||||||
|
background: #f7faff;
|
||||||
|
border-radius: .75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-record-panel,
|
||||||
|
.filter-popup {
|
||||||
|
gap: .875rem;
|
||||||
|
padding: 1rem 0 calc(1rem + env(safe-area-inset-bottom, 0)) 0;
|
||||||
|
display: flex;
|
||||||
|
background: #f5f5f8;
|
||||||
|
border-radius: 1rem 1rem 0 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
h3,
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-popup {
|
||||||
|
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom, 0)) 1rem;
|
||||||
|
|
||||||
|
.items {
|
||||||
|
gap: .5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
||||||
|
.item {
|
||||||
|
border-radius: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.register-record-calendar {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
|
||||||
|
.calendar-head {
|
||||||
|
h2 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-line {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
.day-cell {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(30, 41, 59, .9);
|
||||||
|
|
||||||
|
.count {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
background: rgba(15, 23, 42, .7);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.marked {
|
||||||
|
background: rgba(37, 99, 235, .2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
color: #94a3b8;
|
||||||
|
background: rgba(30, 41, 59, .9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-record-panel,
|
||||||
|
.registration-event-filter-panel {
|
||||||
|
background: #10161f;
|
||||||
|
|
||||||
|
h3,
|
||||||
|
.title {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div class="registration-event-selector">
|
||||||
|
<p v-if="label" class="t-form__label" :class="{required: requiredMark}" v-text="label" />
|
||||||
|
<t-checkbox-group v-model="modelValue" class="group">
|
||||||
|
<t-checkbox
|
||||||
|
v-for="item in registrationEventList"
|
||||||
|
:key="item.id"
|
||||||
|
:value="item.id"
|
||||||
|
class="item"
|
||||||
|
borderless
|
||||||
|
>
|
||||||
|
<span v-text="item.name" />
|
||||||
|
</t-checkbox>
|
||||||
|
</t-checkbox-group>
|
||||||
|
<div v-if="!registrationEventList.length" class="empty-block">暂无可用登记事件</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
registrationEventList: RegistrationEvent[];
|
||||||
|
label?: string;
|
||||||
|
requiredMark?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
label: "登记事件",
|
||||||
|
requiredMark: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelValue = defineModel<string[]>({ required: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.registration-event-selector {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: .5rem;
|
||||||
|
|
||||||
|
.t-form__label {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&.required::before {
|
||||||
|
color: #e34d59;
|
||||||
|
content: "*";
|
||||||
|
margin-right: .25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
width: 100%;
|
||||||
|
gap: .75rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.item) {
|
||||||
|
margin: 0;
|
||||||
|
padding: .875rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f8fbff;
|
||||||
|
border: 1px solid rgba(148, 163, 184, .28);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
color: #52607a;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.registration-event-selector {
|
||||||
|
.label {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block,
|
||||||
|
:deep(.item) {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, .72);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="sliderRef" class="segmented-slider" :style="sliderStyle">
|
||||||
|
<span class="thumb" />
|
||||||
|
<button
|
||||||
|
v-for="(item, index) in options"
|
||||||
|
:key="item.value"
|
||||||
|
:ref="element => setOptionRef(element, index)"
|
||||||
|
class="option"
|
||||||
|
:class="{ active: item.value === modelValue }"
|
||||||
|
type="button"
|
||||||
|
@click="selectOption(item.value)"
|
||||||
|
>
|
||||||
|
<span v-text="item.label" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ComponentPublicInstance } from "vue";
|
||||||
|
|
||||||
|
interface SegmentedSliderOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
options: SegmentedSliderOption[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: string];
|
||||||
|
change: [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const sliderRef = ref<HTMLElement>();
|
||||||
|
const optionRefs = ref<HTMLElement[]>([]);
|
||||||
|
const activeMetrics = reactive({
|
||||||
|
left: 0,
|
||||||
|
width: 0
|
||||||
|
});
|
||||||
|
let sliderResizeObserver: ResizeObserver | undefined;
|
||||||
|
|
||||||
|
const activeIndex = computed(() => {
|
||||||
|
const index = props.options.findIndex(item => item.value === props.modelValue);
|
||||||
|
return 0 < index ? index : 0;
|
||||||
|
});
|
||||||
|
const sliderStyle = computed(() => ({
|
||||||
|
"--thumb-left": `${activeMetrics.left}px`,
|
||||||
|
"--thumb-width": `${activeMetrics.width}px`
|
||||||
|
}));
|
||||||
|
|
||||||
|
function setOptionRef(element: Element | ComponentPublicInstance | null, index: number): void {
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
optionRefs.value[index] = element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateThumbMetrics(): Promise<void> {
|
||||||
|
await nextTick();
|
||||||
|
const activeElement = optionRefs.value[activeIndex.value];
|
||||||
|
if (!activeElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeMetrics.left = activeElement.offsetLeft;
|
||||||
|
activeMetrics.width = activeElement.offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectOption(value: string): void {
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
emit("change", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [
|
||||||
|
props.modelValue,
|
||||||
|
...props.options.map(item => `${item.value}:${item.label}`)
|
||||||
|
], async () => {
|
||||||
|
await updateThumbMetrics();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUpdate(() => {
|
||||||
|
optionRefs.value = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await updateThumbMetrics();
|
||||||
|
if (!sliderRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sliderResizeObserver = new ResizeObserver(() => {
|
||||||
|
void updateThumbMetrics();
|
||||||
|
});
|
||||||
|
sliderResizeObserver.observe(sliderRef.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
sliderResizeObserver?.disconnect();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.segmented-slider {
|
||||||
|
gap: 0;
|
||||||
|
padding: .25rem;
|
||||||
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: .875rem;
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
top: .25rem;
|
||||||
|
left: 0;
|
||||||
|
bottom: .25rem;
|
||||||
|
width: var(--thumb-width);
|
||||||
|
z-index: 0;
|
||||||
|
position: absolute;
|
||||||
|
transform: translateX(var(--thumb-left));
|
||||||
|
background: #fff;
|
||||||
|
transition: width .22s ease, transform .22s ease;
|
||||||
|
border-radius: .625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #52607a;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
height: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 0 .75rem;
|
||||||
|
display: flex;
|
||||||
|
outline: none;
|
||||||
|
position: relative;
|
||||||
|
font-size: .875rem;
|
||||||
|
min-width: 2.75rem;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: inset 0 0 0 .0625rem rgba(15, 118, 110, .48);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.segmented-slider {
|
||||||
|
background: rgba(30, 41, 59, .9);
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
background: rgba(15, 23, 42, .95);
|
||||||
|
box-shadow: 0 .25rem .75rem rgba(2, 6, 23, .36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
color: #94a3b8;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<template>
|
||||||
|
<div class="main-layout">
|
||||||
|
<t-navbar
|
||||||
|
ref="navBarRef"
|
||||||
|
v-if="navBarStore.isShowing"
|
||||||
|
class="nav-bar"
|
||||||
|
:left-arrow="!!navBarStore.canBack"
|
||||||
|
@left-click="doBack"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div id="app-nav-title" class="nav-title-slot">
|
||||||
|
<span v-if="!navBarStore.customTitleVisible" class="nav-title-text" v-text="navBarStore.title" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="navBarStore.customRightVisible || navBarStore.rightIcon || navBarStore.rightText" #right>
|
||||||
|
<div v-if="navBarStore.customRightVisible" id="app-nav-right" class="nav-right-slot" />
|
||||||
|
<button
|
||||||
|
v-else-if="navBarStore.rightIcon"
|
||||||
|
class="nav-btn"
|
||||||
|
type="button"
|
||||||
|
@click="navBarStore.rightAction?.()"
|
||||||
|
>
|
||||||
|
<t-icon :name="navBarStore.rightIcon" size="1.25rem" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
v-text="navBarStore.rightText"
|
||||||
|
class="nav-text"
|
||||||
|
:style="{ color: navBarStore.rightTextColor ? navBarStore.rightTextColor : '#333' }"
|
||||||
|
:class="{ clickable: !!navBarStore.rightAction }"
|
||||||
|
@click="navBarStore.rightAction?.()"
|
||||||
|
></span>
|
||||||
|
</template>
|
||||||
|
</t-navbar>
|
||||||
|
<div class="router-view">
|
||||||
|
<page-transition />
|
||||||
|
</div>
|
||||||
|
<t-tab-bar
|
||||||
|
v-model="tabVal"
|
||||||
|
:class="{ 'is-hidden': !tabBarStore.isShowing }"
|
||||||
|
class="tab-bar glass-white"
|
||||||
|
shape="round"
|
||||||
|
theme="tag"
|
||||||
|
:split="false"
|
||||||
|
:disabled="!tabBarStore.isShowing"
|
||||||
|
@change="onChangeTab"
|
||||||
|
>
|
||||||
|
<t-tab-bar-item v-for="item in list" :key="item.value" class="bg-transparent" :value="item.value">
|
||||||
|
<span v-text="item.text"></span>
|
||||||
|
<template #icon>
|
||||||
|
<t-icon :name="item.icon" />
|
||||||
|
</template>
|
||||||
|
</t-tab-bar-item>
|
||||||
|
</t-tab-bar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import PageTransition from "@/components/PageTransition.vue";
|
||||||
|
import {useNavBarStore} from "@/store/navBarStore";
|
||||||
|
import {useTabBarStore} from "@/store/tabBarStore";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const navBarStore = useNavBarStore();
|
||||||
|
const tabBarStore = useTabBarStore();
|
||||||
|
|
||||||
|
// ---------- 导航高度 ----------
|
||||||
|
|
||||||
|
const navBarRef = ref();
|
||||||
|
let navBarResizeObs: ResizeObserver | undefined;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!navBarRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navBarResizeObs = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
navBarStore.setHeight(entry.contentRect.height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
navBarResizeObs.observe(navBarRef.value.$el.querySelector(".t-navbar__content"));
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (navBarResizeObs) {
|
||||||
|
navBarResizeObs.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 导航返回 ----------
|
||||||
|
|
||||||
|
function doBack(): void {
|
||||||
|
if (navBarStore.backTo) {
|
||||||
|
router.push(navBarStore.backTo);
|
||||||
|
navBarStore.setBackTo(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 导航边距 ----------
|
||||||
|
|
||||||
|
const navBarHeight = computed(() => {
|
||||||
|
if (navBarStore.isShowing) {
|
||||||
|
return "48px";
|
||||||
|
}
|
||||||
|
return "0px";
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Tab 切换 ----------
|
||||||
|
|
||||||
|
const tabVal = ref(route.path);
|
||||||
|
const list = [
|
||||||
|
{ value: "/customers", icon: "usergroup", text: "客户" },
|
||||||
|
{ value: "/management", icon: "setting-1", text: "管理" }
|
||||||
|
];
|
||||||
|
|
||||||
|
watch(() => route.path, (newPath: string) => {
|
||||||
|
tabVal.value = resolveTabValue(newPath);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
function resolveTabValue(path: string): string {
|
||||||
|
if (path.startsWith("/customers")) {
|
||||||
|
return "/customers";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/management")) {
|
||||||
|
return "/management";
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChangeTab(value: string | number): void {
|
||||||
|
const path = String(value);
|
||||||
|
if (resolveTabValue(route.path) === path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tab 边距 ----------
|
||||||
|
|
||||||
|
const isTabBarPaddingNeeded = computed(() => {
|
||||||
|
if (!tabBarStore.isShowing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.meta.tabBarPadding !== false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabBarHeight = computed(() => {
|
||||||
|
if (isTabBarPaddingNeeded.value) {
|
||||||
|
return "6rem";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "0rem";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.main-layout {
|
||||||
|
--app-nav-height: v-bind(navBarHeight);
|
||||||
|
--app-tab-height: v-bind(tabBarHeight);
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
transition: padding-bottom .24s ease;
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
z-index: 999;
|
||||||
|
|
||||||
|
:deep(.t-navbar__content) {
|
||||||
|
color: var(--tui-black, #000);
|
||||||
|
overflow: visible;
|
||||||
|
box-shadow: 0 3px 8px rgba(0, 0, 0, .1);
|
||||||
|
background: rgba(250, 250, 250, .8);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--td-text-color-primary, #333);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title-slot {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title-text {
|
||||||
|
overflow: hidden;
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-right-slot {
|
||||||
|
width: 6rem;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-text {
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-navbar__right),
|
||||||
|
:deep(.t-navbar__right-content) {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-view {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
--td-tab-bar-height: 52px;
|
||||||
|
|
||||||
|
bottom: 1rem;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: opacity 520ms, transform 520ms var(--tui-bezier);
|
||||||
|
|
||||||
|
&.is-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(calc(100% + 1rem));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="root-layout diselect">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({
|
||||||
|
name: "RootLayout"
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.root-layout {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
import {createApp} from "vue";
|
||||||
|
import {createPinia} from "pinia";
|
||||||
|
import router from "@/router";
|
||||||
|
import Root from "@/Root.vue";
|
||||||
|
|
||||||
|
import "tdesign-mobile-vue/es/style/index.css";
|
||||||
|
import "timi-web/style.css";
|
||||||
|
import "timi-tdesign-mobile/style.css";
|
||||||
|
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||||
|
import "@/styles/tdesign-custom.css";
|
||||||
|
import "@/styles/global.less";
|
||||||
|
import {configureUserStore, type ApiResponse, Network, useUserStore, VCan, VRole} from "timi-web";
|
||||||
|
import {Toast} from "tdesign-mobile-vue";
|
||||||
|
|
||||||
|
export const pinia = createPinia();
|
||||||
|
const apiBaseURL = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||||
|
|
||||||
|
if (apiBaseURL) {
|
||||||
|
Network.axios.defaults.baseURL = apiBaseURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
configureUserStore({
|
||||||
|
storageKey: "gao-customer-h5:user"
|
||||||
|
});
|
||||||
|
|
||||||
|
Network.setGlobalRequestInterceptor((config: any) => {
|
||||||
|
const userStore = useUserStore(pinia);
|
||||||
|
if (apiBaseURL && !config.baseURL) {
|
||||||
|
config.baseURL = apiBaseURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
config.headers.set({
|
||||||
|
"Accept-Language": "zh-CN"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userStore.token) {
|
||||||
|
config.headers.set({
|
||||||
|
"Token": userStore.token
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
const app = createApp(Root);
|
||||||
|
app.use(pinia);
|
||||||
|
app.directive("can", VCan);
|
||||||
|
app.directive("role", VRole);
|
||||||
|
|
||||||
|
const userStore = useUserStore(pinia);
|
||||||
|
await userStore.loginByStorage();
|
||||||
|
|
||||||
|
Network.setGlobalErrorCallback((resp: ApiResponse<any>) => {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
direction: "row",
|
||||||
|
message: resp.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(router);
|
||||||
|
app.mount("#root");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await bootstrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
direction: "row",
|
||||||
|
message: "应用启动失败"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void start();
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<section class="login-panel">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="logo">
|
||||||
|
<span>糕</span>
|
||||||
|
</div>
|
||||||
|
<h1>糕店客户管理</h1>
|
||||||
|
</div>
|
||||||
|
<t-form class="form" label-align="left">
|
||||||
|
<t-form-item label="账号">
|
||||||
|
<t-input
|
||||||
|
v-model.trim="formModel.user"
|
||||||
|
type="tel"
|
||||||
|
inputmode="tel"
|
||||||
|
placeholder="请输入账号或手机号"
|
||||||
|
borderless
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="密码">
|
||||||
|
<t-input
|
||||||
|
v-model.trim="formModel.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
borderless
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="验证码">
|
||||||
|
<t-input
|
||||||
|
v-model.trim="formModel.captcha"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
borderless
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<div class="captcha-wrapper">
|
||||||
|
<captcha class="captcha" ref="captchaRef" />
|
||||||
|
</div>
|
||||||
|
<t-button class="submit" theme="primary" size="medium" :loading="isSubmitting" @click="login">登录</t-button>
|
||||||
|
</t-form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {Toast} from "tdesign-mobile-vue";
|
||||||
|
import {Captcha, useUserStore} from "timi-web";
|
||||||
|
|
||||||
|
type CaptchaExpose = {
|
||||||
|
captchaId: string;
|
||||||
|
update: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "Login"
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const formModel = reactive({
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
captcha: ""
|
||||||
|
});
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const captchaRef = ref<CaptchaExpose | null>(null);
|
||||||
|
|
||||||
|
function getCaptchaId(): string {
|
||||||
|
return captchaRef.value?.captchaId || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(): Promise<void> {
|
||||||
|
const captchaId = getCaptchaId();
|
||||||
|
if (!formModel.user || !formModel.password || !formModel.captcha || !captchaId) {
|
||||||
|
Toast({
|
||||||
|
theme: "warning",
|
||||||
|
direction: "row",
|
||||||
|
message: "请输入账号、密码和验证码"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await userStore.login({
|
||||||
|
captchaId,
|
||||||
|
captcha: formModel.captcha,
|
||||||
|
data: {
|
||||||
|
user: formModel.user,
|
||||||
|
password: formModel.password
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/management";
|
||||||
|
await router.replace(redirect);
|
||||||
|
} catch (error) {
|
||||||
|
formModel.captcha = "";
|
||||||
|
await captchaRef.value?.update();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.login-page {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4.5rem 1rem 1.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #F5F5F8;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
max-width: 28rem;
|
||||||
|
background: rgba(255, 255, 255, .94);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.captcha-wrapper {
|
||||||
|
display: flex;
|
||||||
|
margin-top: .5rem;
|
||||||
|
justify-content: end;
|
||||||
|
|
||||||
|
.captcha {
|
||||||
|
border: 1px solid var(--td-border-level-1-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: .875rem 0 0 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
color: #FFF;
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
display: grid;
|
||||||
|
background: #0f766e;
|
||||||
|
box-shadow: 0 .75rem 1.5rem rgba(15, 118, 110, .22);
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
margin: 0;
|
||||||
|
padding: .25rem 0;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
.captcha-row {
|
||||||
|
gap: .75rem;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.tui-captcha) {
|
||||||
|
flex: 0 0 7.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
gap: .5rem;
|
||||||
|
width: 8rem;
|
||||||
|
margin: 2rem auto 0 auto;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.login-page {
|
||||||
|
background: #10161f;
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.tui-captcha) {
|
||||||
|
color: #94a3b8;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="gaoPermissionCodes.registrationEventRecord.create" class="quick-entry-page">
|
||||||
|
<t-form class="form" label-align="left" label-width="6rem">
|
||||||
|
<t-form-item label="客户编码" required-mark>
|
||||||
|
<t-input
|
||||||
|
v-model.trim="scanCode"
|
||||||
|
placeholder="输入或扫描二维码"
|
||||||
|
borderless
|
||||||
|
@blur="applyScanCode"
|
||||||
|
:tips="scanResult"
|
||||||
|
:status="scanStatus"
|
||||||
|
>
|
||||||
|
<template #suffix-icon>
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
size="extra-small"
|
||||||
|
@click="toggleScanner"
|
||||||
|
>
|
||||||
|
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
|
||||||
|
</t-button>
|
||||||
|
</template>
|
||||||
|
</t-input>
|
||||||
|
</t-form-item>
|
||||||
|
<div v-if="scannerVisible" class="code-scanner-wrapper">
|
||||||
|
<code-scanner
|
||||||
|
visible
|
||||||
|
:status-text="scannerTips"
|
||||||
|
active-text="正在识别客户编码,请将二维码移入取景框"
|
||||||
|
@decode="handleDecodedText"
|
||||||
|
@status-change="handleScanStatusChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<t-form-item label="客户姓名" required-mark>
|
||||||
|
<t-input
|
||||||
|
v-model.trim="customerName"
|
||||||
|
placeholder="输入或选择已存档客户"
|
||||||
|
borderless
|
||||||
|
@blur="applyCustomerName"
|
||||||
|
:tips="customerTips"
|
||||||
|
:status="customerStatus"
|
||||||
|
>
|
||||||
|
<template #suffix-icon>
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
size="extra-small"
|
||||||
|
@click="selectorVisible = true"
|
||||||
|
>选择</t-button>
|
||||||
|
</template>
|
||||||
|
</t-input>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="客户照片">
|
||||||
|
<photo-field
|
||||||
|
v-model="supplementPhoto"
|
||||||
|
alt-text="客户照片"
|
||||||
|
:preview="false"
|
||||||
|
@add="selectSupplementPhoto"
|
||||||
|
@clear="clearSupplementPhoto"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label-width="0">
|
||||||
|
<registration-event-selector
|
||||||
|
v-model="selectedRegistrationEventIdList"
|
||||||
|
class="registration-event-selector"
|
||||||
|
:registration-event-list="registrationEventList"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="登记备注">
|
||||||
|
<t-textarea
|
||||||
|
v-model.trim="note"
|
||||||
|
class="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
placeholder="现场说明"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<div class="photo-part">
|
||||||
|
<photo-scroller
|
||||||
|
title="登记事件照片"
|
||||||
|
v-model="photoList"
|
||||||
|
@add="selectRecordPhoto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</t-form>
|
||||||
|
<input
|
||||||
|
ref="supplementPhotoInputRef"
|
||||||
|
class="file-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
@change="handleSupplementPhotoSelected"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="recordPhotoInputRef"
|
||||||
|
class="file-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
@change="handleRecordPhotoSelected"
|
||||||
|
>
|
||||||
|
<t-button theme="primary" class="save" :disabled="!canSubmit" :loading="saving" @click="saveRecord">提交</t-button>
|
||||||
|
</div>
|
||||||
|
<customer-selector
|
||||||
|
v-model="selectedCustomerId"
|
||||||
|
v-model:visible="selectorVisible"
|
||||||
|
@change="handleCustomerChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { AttachmentAPI } from "timi-web";
|
||||||
|
import type { TdInputProps } from "tdesign-mobile-vue";
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import CustomerAPI from "@/api/CustomerAPI";
|
||||||
|
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||||
|
import CodeScanner from "@/components/CodeScanner.vue";
|
||||||
|
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||||
|
import PhotoField from "@/components/PhotoField.vue";
|
||||||
|
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import type { Customer } from "@/types/Customer";
|
||||||
|
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||||
|
import { getCustomerPhotoURL } from "@/utils/customer";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompression";
|
||||||
|
import RegistrationEventSelector from "@/components/RegistrationEventSelector.vue";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "QuickEntryPage"
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const supplementPhoto = ref("");
|
||||||
|
const supplementPhotoTempFileId = ref("");
|
||||||
|
const supplementPhotoInputRef = ref<HTMLInputElement>();
|
||||||
|
const recordPhotoInputRef = ref<HTMLInputElement>();
|
||||||
|
const recordPhotoTempFileIdList = ref<string[]>([]);
|
||||||
|
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||||
|
const selectedRegistrationEventIdList = ref<string[]>([]);
|
||||||
|
const note = ref("");
|
||||||
|
const photoList = ref<string[]>([]);
|
||||||
|
const saving = ref(false);
|
||||||
|
const canSubmit = computed(() => !!getInputCode() && !!customerName.value.trim());
|
||||||
|
let latestDecodedText = "";
|
||||||
|
|
||||||
|
// ---------- 扫码 ----------
|
||||||
|
const scanCode = ref("");
|
||||||
|
const currentCode = ref("");
|
||||||
|
const scanResult = ref("");
|
||||||
|
const scanStatus = ref<TdInputProps["status"]>("warning");
|
||||||
|
const scannerVisible = ref(false);
|
||||||
|
const scannerTips = ref("");
|
||||||
|
const checkedCode = ref("");
|
||||||
|
const boundCodeCustomer = ref<Customer>();
|
||||||
|
|
||||||
|
async function toggleScanner(): Promise<void> {
|
||||||
|
scannerVisible.value = !scannerVisible.value;
|
||||||
|
if (scannerVisible.value) {
|
||||||
|
latestDecodedText = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecodedText(rawText: string): Promise<void> {
|
||||||
|
const normalizedText = rawText.trim();
|
||||||
|
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
latestDecodedText = normalizedText;
|
||||||
|
scanCode.value = normalizedText;
|
||||||
|
await settingStore.playScanSuccessSound();
|
||||||
|
await applyScanCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyScanCode(): Promise<void> {
|
||||||
|
const normalizedCode = scanCode.value.trim();
|
||||||
|
if (!normalizedCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentCode.value = normalizedCode;
|
||||||
|
await checkCustomerCode(normalizedCode);
|
||||||
|
applyBoundCustomerIfNeeded();
|
||||||
|
refreshInputTips();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanStatusChange(value: string): void {
|
||||||
|
scannerTips.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 补录客户 ----------
|
||||||
|
const customerName = ref("");
|
||||||
|
const currentCustomer = ref<Customer>();
|
||||||
|
const customerTips = ref("");
|
||||||
|
const customerStatus = ref<TdInputProps["status"]>("warning");
|
||||||
|
const selectorVisible = ref(false);
|
||||||
|
const selectedCustomerId = ref("");
|
||||||
|
|
||||||
|
async function ensureNewCustomerCode(code: string): Promise<boolean> {
|
||||||
|
await checkCustomerCode(code);
|
||||||
|
applyBoundCustomerIfNeeded();
|
||||||
|
refreshInputTips();
|
||||||
|
return !isCodeBoundToOtherCustomer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCustomerChange(id: string): Promise<void> {
|
||||||
|
const customer = await CustomerAPI.detail(id);
|
||||||
|
currentCustomer.value = customer;
|
||||||
|
selectedCustomerId.value = customer.id || "";
|
||||||
|
customerName.value = customer.name || "";
|
||||||
|
syncSupplementPhotoFromCustomer(customer);
|
||||||
|
customerTips.value = "";
|
||||||
|
if (!getInputCode() && customer.code) {
|
||||||
|
fillCodeFromCustomer(customer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getInputCode()) {
|
||||||
|
await ensureCodeChecked();
|
||||||
|
}
|
||||||
|
refreshInputTips();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCustomerName(): Promise<void> {
|
||||||
|
const normalizedName = customerName.value.trim();
|
||||||
|
if (!normalizedName) {
|
||||||
|
currentCustomer.value = undefined;
|
||||||
|
selectedCustomerId.value = "";
|
||||||
|
customerTips.value = "";
|
||||||
|
refreshInputTips();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matched = await findCustomerByName(normalizedName);
|
||||||
|
if (!matched) {
|
||||||
|
currentCustomer.value = undefined;
|
||||||
|
selectedCustomerId.value = "";
|
||||||
|
customerTips.value = "客户暂无档案,提交登记时会自动新开客户档案";
|
||||||
|
refreshInputTips();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentCustomer.value = matched;
|
||||||
|
selectedCustomerId.value = matched.id || "";
|
||||||
|
customerName.value = matched.name || normalizedName;
|
||||||
|
syncSupplementPhotoFromCustomer(matched);
|
||||||
|
customerTips.value = "已匹配客户";
|
||||||
|
if (!getInputCode() && matched.code) {
|
||||||
|
fillCodeFromCustomer(matched);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getInputCode()) {
|
||||||
|
await ensureCodeChecked();
|
||||||
|
}
|
||||||
|
refreshInputTips();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCustomerByName(name: string): Promise<Customer | undefined> {
|
||||||
|
const list = await CustomerAPI.search({ keyword: name });
|
||||||
|
return list.find(item => item.name?.trim() === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillCodeFromCustomer(customer: Customer): void {
|
||||||
|
const code = customer.code || "";
|
||||||
|
if (!code) {
|
||||||
|
refreshInputTips();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanCode.value = code;
|
||||||
|
currentCode.value = code;
|
||||||
|
checkedCode.value = code;
|
||||||
|
boundCodeCustomer.value = customer;
|
||||||
|
refreshInputTips();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCodeChecked(): Promise<void> {
|
||||||
|
const code = getInputCode();
|
||||||
|
if (!code || checkedCode.value === code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkCustomerCode(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkCustomerCode(code: string): Promise<void> {
|
||||||
|
const normalizedCode = code.trim();
|
||||||
|
if (!normalizedCode) {
|
||||||
|
checkedCode.value = "";
|
||||||
|
boundCodeCustomer.value = undefined;
|
||||||
|
refreshInputTips();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkedCode.value = normalizedCode;
|
||||||
|
boundCodeCustomer.value = await CustomerAPI.nullableDetailByCode(normalizedCode) || undefined;
|
||||||
|
scannerVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBoundCustomerIfNeeded(): void {
|
||||||
|
const boundCustomer = boundCodeCustomer.value;
|
||||||
|
if (!boundCustomer?.id || currentCustomer.value?.id || customerName.value.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCustomer.value = boundCustomer;
|
||||||
|
selectedCustomerId.value = boundCustomer.id;
|
||||||
|
customerName.value = boundCustomer.name || "";
|
||||||
|
syncSupplementPhotoFromCustomer(boundCustomer);
|
||||||
|
customerTips.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshInputTips(): void {
|
||||||
|
scanStatus.value = "warning";
|
||||||
|
customerStatus.value = "warning";
|
||||||
|
|
||||||
|
const code = getInputCode();
|
||||||
|
if (!code) {
|
||||||
|
scanResult.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkedCode.value !== code) {
|
||||||
|
scanResult.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCodeBoundToOtherCustomer()) {
|
||||||
|
scanResult.value = `编码已绑定客户 ${getCustomerName(boundCodeCustomer.value)},请使用新的编码`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boundCodeCustomer.value?.id) {
|
||||||
|
scanResult.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCustomer.value?.code && currentCustomer.value.code !== code) {
|
||||||
|
scanResult.value = `客户已有编码 ${currentCustomer.value.code},将会替换新的编码`;
|
||||||
|
scanStatus.value = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanResult.value = currentCustomer.value?.id ? "提交将会使用此编码" : "编码可用";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCodeBoundToOtherCustomer(): boolean {
|
||||||
|
const boundCustomerId = boundCodeCustomer.value?.id;
|
||||||
|
if (!boundCustomerId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentCustomer.value?.id !== boundCustomerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputCode(): string {
|
||||||
|
return (currentCode.value || scanCode.value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomerName(customer?: Customer): string {
|
||||||
|
return customer?.name || customer?.code || "未命名客户";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 照片 ----------
|
||||||
|
|
||||||
|
function selectSupplementPhoto(): void {
|
||||||
|
supplementPhotoInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSupplementPhoto(): void {
|
||||||
|
supplementPhotoTempFileId.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSupplementPhotoFromCustomer(customer?: Customer): void {
|
||||||
|
supplementPhotoTempFileId.value = "";
|
||||||
|
supplementPhoto.value = getCustomerPhotoURL(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSupplementPhotoSelected(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const compressedFile = await compressUploadImage(file);
|
||||||
|
const [tempFile] = await AttachmentAPI.uploadTemp(compressedFile);
|
||||||
|
if (!tempFile?.id) {
|
||||||
|
showToast("客户照片上传失败", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
supplementPhotoTempFileId.value = tempFile.id;
|
||||||
|
supplementPhoto.value = AttachmentAPI.getTempReadURL(tempFile.id);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRecordPhoto(): void {
|
||||||
|
recordPhotoInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRecordPhotoSelected(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const fileList = Array.from(input.files || []);
|
||||||
|
if (!fileList.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const compressedFileList = await compressUploadImageList(fileList);
|
||||||
|
const tempFileList = await AttachmentAPI.uploadTemp(compressedFileList);
|
||||||
|
for (const item of tempFileList) {
|
||||||
|
if (!item.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
recordPhotoTempFileIdList.value.push(item.id);
|
||||||
|
photoList.value.push(AttachmentAPI.getTempReadURL(item.id));
|
||||||
|
}
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 提交 ----------
|
||||||
|
|
||||||
|
async function saveRecord(): Promise<void> {
|
||||||
|
const customerCode = validateForm();
|
||||||
|
if (!customerCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (!await ensureNewCustomerCode(customerCode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await applyCustomerName();
|
||||||
|
await saveCustomerProfile(customerCode);
|
||||||
|
if (selectedRegistrationEventIdList.value.length) {
|
||||||
|
for (const registrationEventId of selectedRegistrationEventIdList.value) {
|
||||||
|
await CustomerAPI.quickRegister({
|
||||||
|
customerCode,
|
||||||
|
registrationEventId,
|
||||||
|
remark: note.value || "前台快速登记",
|
||||||
|
attachmentIdList: recordPhotoTempFileIdList.value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
note.value = "";
|
||||||
|
customerName.value = "";
|
||||||
|
photoList.value = [];
|
||||||
|
recordPhotoTempFileIdList.value = [];
|
||||||
|
await settingStore.playRegisterSuccessSound();
|
||||||
|
showToast("登记已提交");
|
||||||
|
await router.replace("/customers");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCustomerProfile(customerCode: string): Promise<void> {
|
||||||
|
const normalizedName = customerName.value.trim();
|
||||||
|
const attachmentIdList = supplementPhotoTempFileId.value ? [supplementPhotoTempFileId.value] : undefined;
|
||||||
|
if (currentCustomer.value?.id) {
|
||||||
|
await CustomerAPI.update({
|
||||||
|
...currentCustomer.value,
|
||||||
|
code: customerCode,
|
||||||
|
name: normalizedName,
|
||||||
|
attachmentIdList
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCustomer.value = await CustomerAPI.create({
|
||||||
|
code: customerCode,
|
||||||
|
name: normalizedName,
|
||||||
|
attachmentIdList
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(): string {
|
||||||
|
const customerCode = (currentCode.value || scanCode.value).trim();
|
||||||
|
if (!customerCode) {
|
||||||
|
showToast("请先输入或扫码客户编码", "warning");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!customerName.value.trim()) {
|
||||||
|
showToast("请填写客户姓名", "warning");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return customerCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||||
|
Toast({
|
||||||
|
theme,
|
||||||
|
direction: "row",
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 初始化 ----------
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
registrationEventList.value = await RegistrationEventAPI.enabledList();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(scanCode, (value) => {
|
||||||
|
const normalizedCode = value.trim();
|
||||||
|
currentCode.value = normalizedCode;
|
||||||
|
if (checkedCode.value && checkedCode.value !== normalizedCode) {
|
||||||
|
checkedCode.value = "";
|
||||||
|
boundCodeCustomer.value = undefined;
|
||||||
|
}
|
||||||
|
refreshInputTips();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.quick-entry-page {
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: .5rem 0;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
|
||||||
|
.code-scanner-wrapper,
|
||||||
|
.photo-part {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
padding: 1rem;
|
||||||
|
color: #52607a;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8fbff;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.quick-entry-page {
|
||||||
|
|
||||||
|
:deep(.form-item .t-form__label) {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-event-selector {
|
||||||
|
|
||||||
|
:deep(.item),
|
||||||
|
.empty-block {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, .72);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.textarea .t-textarea__inner) {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, .72);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="gaoPermissionCodes.registrationEventRecord.create" class="quick-sign-in-page">
|
||||||
|
<button class="registration-event-select" type="button" @click="registrationEventSelectorVisible = true">
|
||||||
|
<span class="required-label">登记事件</span>
|
||||||
|
<strong v-text="selectedRegistrationEventName" />
|
||||||
|
<t-icon name="chevron-right" />
|
||||||
|
</button>
|
||||||
|
<code-scanner
|
||||||
|
visible
|
||||||
|
:session-key="scannerSessionKey"
|
||||||
|
:status-text="scanStatus"
|
||||||
|
active-text="请将客户二维码移入取景框"
|
||||||
|
@decode="handleDecodedText"
|
||||||
|
@status-change="handleScanStatusChange"
|
||||||
|
/>
|
||||||
|
<div class="code-box">
|
||||||
|
<span class="required-label">扫出编码</span>
|
||||||
|
<strong v-text="scanCode || '未识别'" />
|
||||||
|
</div>
|
||||||
|
<customer-profile-card
|
||||||
|
v-if="matchedCustomer"
|
||||||
|
:customer="matchedCustomer"
|
||||||
|
/>
|
||||||
|
<t-cell-group
|
||||||
|
v-if="recentRecordList.length"
|
||||||
|
title="最近登记记录"
|
||||||
|
theme="card"
|
||||||
|
>
|
||||||
|
<t-cell
|
||||||
|
v-for="item in recentRecordList"
|
||||||
|
:key="item.id"
|
||||||
|
:title="getRecordRegistrationEventName(item)"
|
||||||
|
:description="formatTime(item.registeredAt)"
|
||||||
|
:note="item.remark || '无备注'"
|
||||||
|
arrow
|
||||||
|
hover
|
||||||
|
@click="goRecordDetail(item.id || '')"
|
||||||
|
>
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-image
|
||||||
|
v-if="getRecordPhotoURL(item)"
|
||||||
|
:src="getRecordPhotoURL(item)"
|
||||||
|
:alt="getRecordRegistrationEventName(item)"
|
||||||
|
fit="cover"
|
||||||
|
class="record-photo"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<t-button
|
||||||
|
class="submit"
|
||||||
|
theme="primary"
|
||||||
|
:disabled="!canSubmitRegister"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="submitRegister"
|
||||||
|
>登记</t-button>
|
||||||
|
</div>
|
||||||
|
<t-popup v-model="registrationEventSelectorVisible" placement="bottom">
|
||||||
|
<div class="registration-event-panel">
|
||||||
|
<div class="title">登记事件</div>
|
||||||
|
<t-cell-group v-if="registrationEventList.length" theme="card">
|
||||||
|
<t-cell
|
||||||
|
v-for="item in registrationEventList"
|
||||||
|
:key="item.id"
|
||||||
|
:title="item.name"
|
||||||
|
:note="item.id === selectedRegistrationEventId ? '当前使用' : ''"
|
||||||
|
@click="selectRegistrationEvent(item.id || '')"
|
||||||
|
/>
|
||||||
|
</t-cell-group>
|
||||||
|
<div v-else class="empty-block">暂无可用登记事件</div>
|
||||||
|
</div>
|
||||||
|
</t-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import CustomerAPI from "@/api/CustomerAPI";
|
||||||
|
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||||
|
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
|
||||||
|
import CodeScanner from "@/components/CodeScanner.vue";
|
||||||
|
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import type { Customer } from "@/types/Customer";
|
||||||
|
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||||
|
import type { RegistrationEventRecord } from "@/types/RegistrationEventRecord";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
import { getRegistrationEventRecordPhotoURL } from "@/utils/registrationEventRecord";
|
||||||
|
import { Time } from "timi-web";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "QuickSignInPage"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const registrationEventStorageKey = "quick-scan-registration-event-id";
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const scanCode = ref("");
|
||||||
|
const scanStatus = ref("正在启动扫码预览。");
|
||||||
|
const scannerSessionKey = ref(0);
|
||||||
|
const matchedCustomer = ref<Customer>();
|
||||||
|
const recentRecordList = ref<RegistrationEventRecord[]>([]);
|
||||||
|
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||||
|
const selectedRegistrationEventId = ref("");
|
||||||
|
const registrationEventSelectorVisible = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
let latestDecodedText = "";
|
||||||
|
|
||||||
|
const selectedRegistrationEventName = computed(() => registrationEventList.value.find(item => item.id === selectedRegistrationEventId.value)?.name || "请选择登记事件");
|
||||||
|
const canSubmitRegister = computed(() => !!matchedCustomer.value && !!selectedRegistrationEventId.value);
|
||||||
|
|
||||||
|
// ---------- 扫码 ----------
|
||||||
|
|
||||||
|
async function handleDecodedText(rawText: string): Promise<void> {
|
||||||
|
const normalizedText = rawText.trim();
|
||||||
|
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
latestDecodedText = normalizedText;
|
||||||
|
scanCode.value = normalizedText;
|
||||||
|
await settingStore.playScanSuccessSound();
|
||||||
|
await loadCustomer(normalizedText);
|
||||||
|
if (matchedCustomer.value) {
|
||||||
|
scanStatus.value = `已识别 ${normalizedText},可直接登记。`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanStatus.value = `已识别 ${normalizedText},但未匹配客户档案。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanStatusChange(value: string): void {
|
||||||
|
scanStatus.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 提交 ----------
|
||||||
|
|
||||||
|
async function submitRegister(): Promise<void> {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = matchedCustomer.value;
|
||||||
|
if (!customer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await CustomerAPI.quickRegister({
|
||||||
|
customerCode: customer.code || scanCode.value,
|
||||||
|
registrationEventId: selectedRegistrationEventId.value,
|
||||||
|
remark: "客户快速扫码登记"
|
||||||
|
});
|
||||||
|
scanStatus.value = `登记成功:${customer.name || customer.code || scanCode.value}。`;
|
||||||
|
scanCode.value = "";
|
||||||
|
matchedCustomer.value = undefined;
|
||||||
|
recentRecordList.value = [];
|
||||||
|
latestDecodedText = "";
|
||||||
|
scannerSessionKey.value += 1;
|
||||||
|
await settingStore.playRegisterSuccessSound();
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(): boolean {
|
||||||
|
if (!selectedRegistrationEventId.value) {
|
||||||
|
showToast("请先选择登记事件", "warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!matchedCustomer.value) {
|
||||||
|
scanStatus.value = "当前编码未匹配客户,无法登记。";
|
||||||
|
showToast("请先扫码匹配客户", "warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomer(code: string): Promise<void> {
|
||||||
|
const customer = await CustomerAPI.nullableDetailByCode(code);
|
||||||
|
if (!customer) {
|
||||||
|
matchedCustomer.value = undefined;
|
||||||
|
recentRecordList.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedCustomer.value = customer;
|
||||||
|
await loadRecentRecordList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecentRecordList(): Promise<void> {
|
||||||
|
const customerId = matchedCustomer.value?.id;
|
||||||
|
if (!customerId) {
|
||||||
|
recentRecordList.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recentRecordList.value = (await RegistrationEventRecordAPI.rangeList({
|
||||||
|
customerId,
|
||||||
|
beginRegisteredAt: 0,
|
||||||
|
endRegisteredAt: Time.toTimestamp(new Date())
|
||||||
|
}))
|
||||||
|
.sort((left, right) => (right.registeredAt || 0) - (left.registeredAt || 0))
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goRecordDetail(id: string): Promise<void> {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await router.push(`/management/registration-event-records/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordRegistrationEventName(record: RegistrationEventRecord): string {
|
||||||
|
return record.registrationEvent?.name || "未知事件";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordPhotoURL(record: RegistrationEventRecord): string {
|
||||||
|
return getRegistrationEventRecordPhotoURL(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: number): string {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = Time.toDateObject(value);
|
||||||
|
if (!date) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return Time.formatDateTime(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 登记事件 ----------
|
||||||
|
|
||||||
|
async function loadActions(): Promise<void> {
|
||||||
|
registrationEventList.value = await RegistrationEventAPI.enabledList();
|
||||||
|
const storedRegistrationEventId = loadStoredRegistrationEventId();
|
||||||
|
const storedAction = registrationEventList.value.find(item => item.id === storedRegistrationEventId);
|
||||||
|
selectedRegistrationEventId.value = storedAction?.id || registrationEventList.value[0]?.id || "";
|
||||||
|
saveStoredRegistrationEventId(selectedRegistrationEventId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRegistrationEvent(registrationEventId: string | number | Array<string | number>): void {
|
||||||
|
if (Array.isArray(registrationEventId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRegistrationEventId = String(registrationEventId);
|
||||||
|
if (!nextRegistrationEventId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedRegistrationEventId.value = nextRegistrationEventId;
|
||||||
|
saveStoredRegistrationEventId(nextRegistrationEventId);
|
||||||
|
registrationEventSelectorVisible.value = false;
|
||||||
|
showToast("登记事件已切换");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStoredRegistrationEventId(): string {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.localStorage.getItem(registrationEventStorageKey) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStoredRegistrationEventId(registrationEventId: string): void {
|
||||||
|
if (typeof window === "undefined" || !registrationEventId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(registrationEventStorageKey, registrationEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||||
|
Toast({
|
||||||
|
theme,
|
||||||
|
direction: "row",
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadActions);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.quick-sign-in-page {
|
||||||
|
gap: 1rem;
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.panel,
|
||||||
|
.bottom-bar {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, .94);
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-event-select {
|
||||||
|
gap: .5rem;
|
||||||
|
border: 0;
|
||||||
|
padding: .875rem 1rem;
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
background: rgba(255, 255, 255, .94);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
|
||||||
|
align-items: center;
|
||||||
|
border-radius: .875rem;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0f172a;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 1rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-box {
|
||||||
|
padding: .875rem;
|
||||||
|
background: #f7faff;
|
||||||
|
border: 1px solid rgba(148, 163, 184, .2);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-label::before {
|
||||||
|
color: #e34d59;
|
||||||
|
content: "*";
|
||||||
|
margin-right: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-box {
|
||||||
|
display: grid;
|
||||||
|
margin-top: .875rem;
|
||||||
|
gap: .375rem;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-photo {
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: .875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-event-panel {
|
||||||
|
gap: .875rem;
|
||||||
|
padding: 1rem 0 calc(1rem + env(safe-area-inset-bottom, 0)) 0;
|
||||||
|
display: flex;
|
||||||
|
background: #f5f5f8;
|
||||||
|
border-radius: 1rem 1rem 0 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
color: #52607a;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.quick-sign-in-page {
|
||||||
|
.panel,
|
||||||
|
.bottom-bar {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-event-select {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-box {
|
||||||
|
background: rgba(30, 41, 59, .9);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-box span {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-event-panel {
|
||||||
|
background: #10161f;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="gaoPermissionCodes.stat.read" class="customer-data-charts-page">
|
||||||
|
<div class="selector">
|
||||||
|
<div class="header">
|
||||||
|
<t-button class="list-select" block theme="primary" variant="outline" @click="selectorVisible = true">
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="user-search" />
|
||||||
|
</template>
|
||||||
|
<span v-text="selectedCustomer ? selectedCustomer.name : '选择客户'" />
|
||||||
|
</t-button>
|
||||||
|
<t-button class="scan-select" theme="primary" @click="toggleScanner">
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="scan" />
|
||||||
|
</template>
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="scannerVisible" class="scanner-wrapper">
|
||||||
|
<code-scanner
|
||||||
|
visible
|
||||||
|
:status-text="scanStatus"
|
||||||
|
active-text="正在识别客户编码,请将二维码移入取景框"
|
||||||
|
@decode="handleDecodedText"
|
||||||
|
@status-change="handleScanStatusChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section v-if="selectedCustomer" class="summary">
|
||||||
|
<div v-for="item in summaryList" :key="item.label" class="summary-item">
|
||||||
|
<span v-text="item.label" />
|
||||||
|
<strong v-text="item.value" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<registration-event-record-calendar
|
||||||
|
v-if="selectedCustomer"
|
||||||
|
v-model="monthCursor"
|
||||||
|
v-model:selected-registration-event-id-list="selectedRegistrationEventIdList"
|
||||||
|
class="calendar-section"
|
||||||
|
:registration-event-list="registrationEventList"
|
||||||
|
:customer-name="selectedCustomer.name"
|
||||||
|
:loading="calendarLoading"
|
||||||
|
:records="monthRecords"
|
||||||
|
/>
|
||||||
|
<section
|
||||||
|
v-for="item in chartList"
|
||||||
|
:key="item.id"
|
||||||
|
class="chart-section"
|
||||||
|
>
|
||||||
|
<h2 v-text="item.title" />
|
||||||
|
<div :ref="element => setChartRef(item.id, element)" class="chart" />
|
||||||
|
</section>
|
||||||
|
<div v-if="!selectedCustomer" class="empty-block">请选择客户后查看专属图表</div>
|
||||||
|
<customer-selector
|
||||||
|
v-model="selectedCustomerId"
|
||||||
|
v-model:visible="selectorVisible"
|
||||||
|
@change="handleCustomerSelected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {ECharts, EChartsOption} from "echarts";
|
||||||
|
import {BarChart, LineChart} from "echarts/charts";
|
||||||
|
import {GridComponent, LegendComponent, TooltipComponent} from "echarts/components";
|
||||||
|
import {init, use} from "echarts/core";
|
||||||
|
import {CanvasRenderer} from "echarts/renderers";
|
||||||
|
import CustomerAPI from "@/api/CustomerAPI";
|
||||||
|
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||||
|
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
|
||||||
|
import type {Customer} from "@/types/Customer";
|
||||||
|
import type {RegistrationEvent} from "@/types/RegistrationEvent";
|
||||||
|
import type {RegistrationEventRecord} from "@/types/RegistrationEventRecord";
|
||||||
|
import CodeScanner from "@/components/CodeScanner.vue";
|
||||||
|
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||||
|
import RegistrationEventRecordCalendar from "@/components/RegistrationEventRecordCalendar.vue";
|
||||||
|
import {useSettingStore} from "@/store/settingStore";
|
||||||
|
import {Time} from "timi-web";
|
||||||
|
import {gaoPermissionCodes} from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
interface SummaryItem {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartProfile {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
option: EChartsOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "CustomerDataCharts"
|
||||||
|
});
|
||||||
|
|
||||||
|
use([
|
||||||
|
BarChart,
|
||||||
|
LineChart,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
CanvasRenderer
|
||||||
|
]);
|
||||||
|
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const chartRefs = new Map<string, HTMLElement>();
|
||||||
|
const chartInstances = new Map<string, ECharts>();
|
||||||
|
const routeCustomerId = computed(() => String(route.params.id || ""));
|
||||||
|
const routeCustomerCode = computed(() => String(route.query.customerCode || ""));
|
||||||
|
const customerCode = ref(routeCustomerCode.value);
|
||||||
|
const selectedCustomerId = ref(routeCustomerId.value);
|
||||||
|
const selectorVisible = ref(false);
|
||||||
|
const scannerVisible = ref(false);
|
||||||
|
const scanStatus = ref("点击扫码开始识别。");
|
||||||
|
const selectedCustomer = ref<Customer>();
|
||||||
|
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||||
|
const selectedRegistrationEventIdList = ref<string[]>([]);
|
||||||
|
const monthCursor = ref(getMonthStart(new Date()));
|
||||||
|
const monthRecords = ref<RegistrationEventRecord[]>([]);
|
||||||
|
const calendarLoading = ref(false);
|
||||||
|
let latestDecodedText = "";
|
||||||
|
|
||||||
|
const registrationEventNameMap = computed(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const item of registrationEventList.value) {
|
||||||
|
if (item.id) {
|
||||||
|
map.set(item.id, item.name || "未命名事件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const selectedRegistrationEventIdSet = computed(() => new Set(selectedRegistrationEventIdList.value));
|
||||||
|
const filteredMonthRecords = computed(() => {
|
||||||
|
if (!selectedRegistrationEventIdList.value.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return monthRecords.value.filter(item => {
|
||||||
|
if (!item.registrationEventId || !selectedRegistrationEventIdSet.value.has(item.registrationEventId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const recordDate = Time.toDateObject(item.registeredAt);
|
||||||
|
return !!recordDate && recordDate.getFullYear() === monthCursor.value.getFullYear() && recordDate.getMonth() === monthCursor.value.getMonth();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const monthDateTextList = computed(() => buildMonthDateTextList(monthCursor.value));
|
||||||
|
const summaryList = computed<SummaryItem[]>(() => [
|
||||||
|
{
|
||||||
|
label: "本月登记",
|
||||||
|
value: `${filteredMonthRecords.value.length} 次`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "转介绍",
|
||||||
|
value: `${selectedCustomer.value?.invitedCount || 0} 人`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "最近登记",
|
||||||
|
value: getRecordRegistrationEventName(filteredMonthRecords.value[0]) || "暂无"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const chartList = computed<ChartProfile[]>(() => {
|
||||||
|
if (!selectedCustomer.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "customer-record-trend",
|
||||||
|
title: "客户登记趋势",
|
||||||
|
option: buildRecordTrendOption()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "customer-event-structure",
|
||||||
|
title: "登记事件结构",
|
||||||
|
option: buildBehaviorStructureOption()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "customer-relation",
|
||||||
|
title: "客户关系数据",
|
||||||
|
option: buildRelationOption()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(chartList, async () => {
|
||||||
|
await nextTick();
|
||||||
|
renderCharts();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(routeCustomerCode, (value) => {
|
||||||
|
if (value) {
|
||||||
|
customerCode.value = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(routeCustomerId, async (value) => {
|
||||||
|
if (value) {
|
||||||
|
await loadCustomerById(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(customerCode, async (value) => {
|
||||||
|
await loadCustomer(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([selectedCustomer, monthCursor], async () => {
|
||||||
|
await loadMonthRecords();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadActions();
|
||||||
|
if (routeCustomerId.value) {
|
||||||
|
await loadCustomerById(routeCustomerId.value);
|
||||||
|
} else if (customerCode.value) {
|
||||||
|
await loadCustomer(customerCode.value);
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
renderCharts();
|
||||||
|
window.addEventListener("resize", resizeCharts);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("resize", resizeCharts);
|
||||||
|
chartInstances.forEach(item => item.dispose());
|
||||||
|
chartInstances.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setChartRef(id: string, element: Element | ComponentPublicInstance | null): void {
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
chartRefs.set(id, element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCharts(): void {
|
||||||
|
for (const item of chartList.value) {
|
||||||
|
const element = chartRefs.get(item.id);
|
||||||
|
if (!element) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chart = getChartInstance(item.id, element);
|
||||||
|
chart.setOption(item.option, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartInstance(id: string, element: HTMLElement): ECharts {
|
||||||
|
const instance = chartInstances.get(id);
|
||||||
|
if (instance) {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chart = init(element);
|
||||||
|
chartInstances.set(id, chart);
|
||||||
|
return chart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCharts(): void {
|
||||||
|
chartInstances.forEach(item => item.resize());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 数据 ----------
|
||||||
|
|
||||||
|
async function loadActions(): Promise<void> {
|
||||||
|
registrationEventList.value = await RegistrationEventAPI.enabledList();
|
||||||
|
selectedRegistrationEventIdList.value = registrationEventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomer(code: string): Promise<void> {
|
||||||
|
const normalizedCode = code.trim();
|
||||||
|
if (!normalizedCode) {
|
||||||
|
selectedCustomer.value = undefined;
|
||||||
|
selectedCustomerId.value = "";
|
||||||
|
monthRecords.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await CustomerAPI.nullableDetailByCode(normalizedCode);
|
||||||
|
if (!customer) {
|
||||||
|
selectedCustomer.value = undefined;
|
||||||
|
selectedCustomerId.value = "";
|
||||||
|
monthRecords.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCustomer.value = customer;
|
||||||
|
selectedCustomerId.value = customer.id || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomerById(id: string): Promise<void> {
|
||||||
|
const normalizedId = id.trim();
|
||||||
|
if (!normalizedId) {
|
||||||
|
selectedCustomer.value = undefined;
|
||||||
|
selectedCustomerId.value = "";
|
||||||
|
monthRecords.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
selectedCustomer.value = await CustomerAPI.detail(normalizedId);
|
||||||
|
selectedCustomerId.value = selectedCustomer.value.id || "";
|
||||||
|
} catch (err) {
|
||||||
|
selectedCustomer.value = undefined;
|
||||||
|
selectedCustomerId.value = "";
|
||||||
|
monthRecords.value = [];
|
||||||
|
console.warn("客户详情加载失败", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCustomerSelected(id: string): Promise<void> {
|
||||||
|
await loadCustomerById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMonthRecords(): Promise<void> {
|
||||||
|
const customerId = selectedCustomer.value?.id;
|
||||||
|
if (!customerId) {
|
||||||
|
monthRecords.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beginDate = getCalendarBeginDate(monthCursor.value);
|
||||||
|
const endDate = getDateEnd(addDays(beginDate, 41));
|
||||||
|
calendarLoading.value = true;
|
||||||
|
try {
|
||||||
|
monthRecords.value = await RegistrationEventRecordAPI.rangeList({
|
||||||
|
customerId,
|
||||||
|
beginRegisteredAt: Time.toTimestamp(beginDate),
|
||||||
|
endRegisteredAt: Time.toTimestamp(endDate)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
calendarLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 扫码 ----------
|
||||||
|
|
||||||
|
async function toggleScanner(): Promise<void> {
|
||||||
|
scannerVisible.value = !scannerVisible.value;
|
||||||
|
if (scannerVisible.value) {
|
||||||
|
latestDecodedText = "";
|
||||||
|
scanStatus.value = "正在启动扫码预览。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecodedText(rawText: string): Promise<void> {
|
||||||
|
const normalizedText = rawText.trim();
|
||||||
|
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
latestDecodedText = normalizedText;
|
||||||
|
await settingStore.playScanSuccessSound();
|
||||||
|
customerCode.value = normalizedText;
|
||||||
|
scanStatus.value = `已识别 ${normalizedText},正在加载客户档案。`;
|
||||||
|
scannerVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanStatusChange(value: string): void {
|
||||||
|
scanStatus.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordDateText(record: RegistrationEventRecord): string {
|
||||||
|
const date = Time.toDateObject(record.registeredAt);
|
||||||
|
return date ? Time.formatDate(date) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordRegistrationEventName(record?: RegistrationEventRecord): string {
|
||||||
|
if (!record) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return record.registrationEvent?.name || (record.registrationEventId ? registrationEventNameMap.value.get(record.registrationEventId) : "") || "未知事件";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 图表 ----------
|
||||||
|
|
||||||
|
function buildBaseOption(): EChartsOption {
|
||||||
|
return {
|
||||||
|
color: ["#2563eb", "#0f766e", "#d97706", "#db2777", "#7c3aed"],
|
||||||
|
grid: {
|
||||||
|
top: 48,
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
bottom: 12,
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
top: 0,
|
||||||
|
icon: "roundRect",
|
||||||
|
textStyle: {
|
||||||
|
color: "#52607a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis"
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: monthDateTextList.value.map(item => item.slice(5)),
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: "#52607a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: "#52607a"
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: "rgba(148, 163, 184, .2)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecordTrendOption(): EChartsOption {
|
||||||
|
return {
|
||||||
|
...buildBaseOption(),
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "登记次数",
|
||||||
|
type: "line",
|
||||||
|
smooth: true,
|
||||||
|
data: monthDateTextList.value.map(date => filteredMonthRecords.value.filter(item => getRecordDateText(item) === date).length)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBehaviorStructureOption(): EChartsOption {
|
||||||
|
return {
|
||||||
|
...buildBaseOption(),
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: registrationEventList.value.filter(item => item.id && selectedRegistrationEventIdSet.value.has(item.id)).map(item => item.name || "未命名事件"),
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: "#52607a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "登记次数",
|
||||||
|
type: "bar",
|
||||||
|
data: registrationEventList.value
|
||||||
|
.filter(item => item.id && selectedRegistrationEventIdSet.value.has(item.id))
|
||||||
|
.map(item => ({
|
||||||
|
value: filteredMonthRecords.value.filter(record => record.registrationEventId === item.id).length,
|
||||||
|
itemStyle: getRegistrationEventColor(item) ? {
|
||||||
|
color: getRegistrationEventColor(item)
|
||||||
|
} : undefined
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegistrationEventColor(registrationEvent: RegistrationEvent): string | undefined {
|
||||||
|
return registrationEvent.color || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRelationOption(): EChartsOption {
|
||||||
|
return {
|
||||||
|
...buildBaseOption(),
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: ["介绍人", "转介绍客户"],
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: "#52607a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "客户数",
|
||||||
|
type: "bar",
|
||||||
|
data: [selectedCustomer.value?.introducerCustomerId ? 1 : 0, selectedCustomer.value?.invitedCount || 0]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 日期 ----------
|
||||||
|
|
||||||
|
function getMonthStart(date: Date): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarBeginDate(date: Date): Date {
|
||||||
|
const monthStart = getMonthStart(date);
|
||||||
|
return addDays(monthStart, -monthStart.getDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonthDateTextList(date: Date): string[] {
|
||||||
|
const monthStart = getMonthStart(date);
|
||||||
|
const nextMonthStart = addMonths(monthStart, 1);
|
||||||
|
const list: string[] = [];
|
||||||
|
for (let item = monthStart; item < nextMonthStart; item = addDays(item, 1)) {
|
||||||
|
list.push(Time.formatDate(item));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number): Date {
|
||||||
|
const value = new Date(date);
|
||||||
|
value.setDate(value.getDate() + days);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(date: Date, months: number): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth() + months, 1, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateEnd(date: Date): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.customer-data-charts-page {
|
||||||
|
gap: 1rem;
|
||||||
|
padding: var(--app-nav-height) 0 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.selector {
|
||||||
|
gap: .75rem;
|
||||||
|
top: var(--app-nav-height);
|
||||||
|
z-index: 10;
|
||||||
|
padding: 1rem 1rem .875rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
position: sticky;
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
background: #f5f5f8;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.list-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-select {
|
||||||
|
flex: 0 0 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
gap: .75rem;
|
||||||
|
margin: 0 1rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
padding: .875rem .625rem;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
align-items: center;
|
||||||
|
border-radius: .75rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
margin-top: .375rem;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-section {
|
||||||
|
margin: 0 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: .75rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-section {
|
||||||
|
margin: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-section {
|
||||||
|
h2 {
|
||||||
|
margin-bottom: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 16rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
padding: 1rem;
|
||||||
|
color: #52607a;
|
||||||
|
margin: 0 1rem;
|
||||||
|
text-align: center;
|
||||||
|
background: #f7faff;
|
||||||
|
border-radius: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.customer-data-charts-page {
|
||||||
|
.selector {
|
||||||
|
background: #10161f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
.summary-item {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-section,
|
||||||
|
.chart-section {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
color: #94a3b8;
|
||||||
|
background: rgba(30, 41, 59, .9);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="gaoPermissionCodes.customer.read" class="customer-detail">
|
||||||
|
<customer-profile-card
|
||||||
|
class="customer"
|
||||||
|
:customer="customer"
|
||||||
|
/>
|
||||||
|
<t-cell-group v-if="customer" theme="card">
|
||||||
|
<t-cell title="住址" :note="customer.address || '无'" />
|
||||||
|
<t-cell title="病症" :note="customer.disease || '无'" />
|
||||||
|
<t-cell title="备注" :note="customer.remark || '无'" />
|
||||||
|
<t-cell title="录入用户" :note="creatorName" />
|
||||||
|
<t-cell title="录入时间" :note="createdAtText" />
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group v-if="customer && canReadStat" theme="card">
|
||||||
|
<t-cell title="客户数据图表" arrow @click="goCustomerDataCharts">
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-icon name="chart" size="1.5rem" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group theme="card">
|
||||||
|
<div class="conditioning-part bg-white">
|
||||||
|
<span class="gray">调理部位</span>
|
||||||
|
<body-area-selector v-model="selectedAreas" />
|
||||||
|
</div>
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group v-if="inviter" :title="`被 ${inviter.name} 邀请`" class="invite" theme="card">
|
||||||
|
<t-cell
|
||||||
|
:title="inviter.name"
|
||||||
|
:description="inviter.telephone || '未填写电话'"
|
||||||
|
arrow
|
||||||
|
@click="goCustomerDetail(inviter)"
|
||||||
|
>
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-image
|
||||||
|
v-if="getCustomerPhotoURL(inviter)"
|
||||||
|
class="avatar"
|
||||||
|
:src="getCustomerPhotoURL(inviter)"
|
||||||
|
:alt="inviter.name"
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
|
<div v-else class="avatar fallback" v-text="(inviter.name || '客').slice(0, 1)" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<div class="invite">
|
||||||
|
<div class="header">
|
||||||
|
<div class="title">邀请客户</div>
|
||||||
|
<div v-can="gaoPermissionCodes.customer.update" class="actions">
|
||||||
|
<t-button
|
||||||
|
size="extra-small"
|
||||||
|
content="选择添加"
|
||||||
|
@click="selectorVisible = true"
|
||||||
|
/>
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
size="extra-small"
|
||||||
|
content="扫码添加"
|
||||||
|
@click="openScanPopup"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<t-cell-group theme="card">
|
||||||
|
<t-swipe-cell
|
||||||
|
class="item"
|
||||||
|
v-for="item in invitees"
|
||||||
|
:key="item.id || item.code"
|
||||||
|
>
|
||||||
|
<t-cell
|
||||||
|
:description="item.telephone || '未填写电话'"
|
||||||
|
arrow
|
||||||
|
hover
|
||||||
|
@click="goCustomerDetail(item)"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="title">
|
||||||
|
<span v-text="item.name || item.code" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-image
|
||||||
|
v-if="getCustomerPhotoURL(item)"
|
||||||
|
class="avatar"
|
||||||
|
:src="getCustomerPhotoURL(item)"
|
||||||
|
:alt="item.name"
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
|
<div v-else class="avatar fallback" v-text="(item.name || '客').slice(0, 1)" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<template #right>
|
||||||
|
<div v-can="gaoPermissionCodes.customer.update" class="remove bg-red white" @click="removeInvitee(item)">删除</div>
|
||||||
|
</template>
|
||||||
|
</t-swipe-cell>
|
||||||
|
<div v-if="!invitees.length" class="empty">暂无邀请客户</div>
|
||||||
|
</t-cell-group>
|
||||||
|
</div>
|
||||||
|
<div class="bottom-actions">
|
||||||
|
<t-button
|
||||||
|
v-can="gaoPermissionCodes.customer.delete"
|
||||||
|
class="delete bg-white"
|
||||||
|
theme="danger"
|
||||||
|
variant="outline"
|
||||||
|
:loading="removing"
|
||||||
|
@click="confirmRemoveCustomer"
|
||||||
|
>
|
||||||
|
删除客户
|
||||||
|
</t-button>
|
||||||
|
<t-button v-can="gaoPermissionCodes.customer.update" theme="primary" class="edit" @click="toEdit">编辑客户</t-button>
|
||||||
|
</div>
|
||||||
|
<customer-selector
|
||||||
|
v-model="selectedInviteeId"
|
||||||
|
v-model:visible="selectorVisible"
|
||||||
|
@change="handleInviteeSelected"
|
||||||
|
/>
|
||||||
|
<t-popup v-model="scanPopupVisible" placement="bottom">
|
||||||
|
<div class="scan-popup bg-white">
|
||||||
|
<div class="header">
|
||||||
|
<t-button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="closeScanPopup"
|
||||||
|
content="取消"
|
||||||
|
/>
|
||||||
|
<h3>扫码添加邀请客户</h3>
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
size="small"
|
||||||
|
content="确认"
|
||||||
|
@click="confirmScannedInvitee"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<code-scanner
|
||||||
|
v-if="scanPopupVisible"
|
||||||
|
:visible="scanPopupVisible"
|
||||||
|
:status-text="scanStatus"
|
||||||
|
active-text="正在识别客户编码,请将二维码移入取景框"
|
||||||
|
@decode="handleDecodedText"
|
||||||
|
@status-change="handleScanStatusChange"
|
||||||
|
/>
|
||||||
|
<div v-if="pendingInvitee" class="confirm">
|
||||||
|
<customer-profile-card :customer="pendingInvitee" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t-popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
|
||||||
|
import { Time, useUserStore } from "timi-web";
|
||||||
|
import CustomerAPI from "@/api/CustomerAPI";
|
||||||
|
import BodyAreaSelector from "@/components/BodyAreaSelector.vue";
|
||||||
|
import CodeScanner from "@/components/CodeScanner.vue";
|
||||||
|
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||||
|
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import type { BodyAreaValue, Customer } from "@/types/Customer";
|
||||||
|
import { getCustomerPhotoURL, normalizeBodyAreaValue } from "@/utils/customer";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "CustomerDetail"
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
const customer = ref<Customer>();
|
||||||
|
const invitees = ref<Customer[]>([]);
|
||||||
|
const selectedInviteeId = ref("");
|
||||||
|
const selectorVisible = ref(false);
|
||||||
|
const scanPopupVisible = ref(false);
|
||||||
|
const pendingInvitee = ref<Customer>();
|
||||||
|
const scanStatus = ref("点击扫码开始识别。");
|
||||||
|
const removing = ref(false);
|
||||||
|
let latestDecodedText = "";
|
||||||
|
|
||||||
|
const inviter = computed(() => customer.value?.introducerCustomer);
|
||||||
|
const canReadStat = computed(() => userStore.hasPermissions(gaoPermissionCodes.stat.read));
|
||||||
|
const creatorName = computed(() => {
|
||||||
|
const creatorUser = customer.value?.creatorUser;
|
||||||
|
return creatorUser?.nick || creatorUser?.name || customer.value?.creatorUserId || "无";
|
||||||
|
});
|
||||||
|
const createdAtText = computed(() => formatTime(customer.value?.createdAt));
|
||||||
|
const selectedAreas = computed<BodyAreaValue>({
|
||||||
|
get() {
|
||||||
|
return normalizeBodyAreaValue(customer.value?.conditioningPart);
|
||||||
|
},
|
||||||
|
set() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCustomer(): Promise<void> {
|
||||||
|
const id = String(route.params.id || "");
|
||||||
|
if (!id) {
|
||||||
|
customer.value = undefined;
|
||||||
|
invitees.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
customer.value = await CustomerAPI.detail(id);
|
||||||
|
await loadInvitees();
|
||||||
|
} catch (err) {
|
||||||
|
customer.value = undefined;
|
||||||
|
invitees.value = [];
|
||||||
|
console.warn("客户详情加载失败", err);
|
||||||
|
showToast("客户详情加载失败", "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInvitees(): Promise<void> {
|
||||||
|
const customerId = customer.value?.id;
|
||||||
|
if (!customerId) {
|
||||||
|
invitees.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
invitees.value = await CustomerAPI.invitedList(customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeInvitee(item: Customer): Promise<void> {
|
||||||
|
if (!item.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CustomerAPI.deleteInvite(item.id);
|
||||||
|
await loadCustomer();
|
||||||
|
showToast("已删除邀请关系");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoveCustomer(): Promise<void> {
|
||||||
|
const currentCustomer = customer.value;
|
||||||
|
if (!currentCustomer?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerId = currentCustomer.id;
|
||||||
|
const customerName = currentCustomer.name || currentCustomer.code || "未命名客户";
|
||||||
|
const dialog = DialogPlugin.confirm({
|
||||||
|
title: "删除客户",
|
||||||
|
content: `确定删除客户 ${customerName} 吗?删除后无法恢复。`,
|
||||||
|
cancelBtn: "取消",
|
||||||
|
confirmBtn: "删除",
|
||||||
|
onCancel() {
|
||||||
|
dialog.destroy();
|
||||||
|
},
|
||||||
|
async onConfirm() {
|
||||||
|
dialog.destroy();
|
||||||
|
await removeCustomer(customerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCustomer(id: string): Promise<void> {
|
||||||
|
removing.value = true;
|
||||||
|
try {
|
||||||
|
await CustomerAPI.remove(id);
|
||||||
|
showToast("客户已删除");
|
||||||
|
await router.replace("/management/customers");
|
||||||
|
} finally {
|
||||||
|
removing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toEdit(): Promise<void> {
|
||||||
|
if (!customer.value?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await router.push(`/management/customers/${customer.value.id}/edit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||||
|
Toast({
|
||||||
|
theme,
|
||||||
|
direction: "row",
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: number): string {
|
||||||
|
if (!value) {
|
||||||
|
return "无";
|
||||||
|
}
|
||||||
|
const date = Time.toDateObject(value);
|
||||||
|
if (!date) {
|
||||||
|
return "无";
|
||||||
|
}
|
||||||
|
return Time.formatDateTime(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCustomerAncestor(invitee: Customer): boolean {
|
||||||
|
if (!customer.value?.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = customer.value.introducerCustomer;
|
||||||
|
while (current?.id) {
|
||||||
|
if (current.id === invitee.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = current.introducerCustomer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addInvitee(invitee: Customer): Promise<boolean> {
|
||||||
|
if (!customer.value?.id || !invitee.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (invitee.id === customer.value.id) {
|
||||||
|
showToast("不能把客户本人添加为邀请客户", "warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isCustomerAncestor(invitee)) {
|
||||||
|
showToast("不能把上级邀请人添加为邀请客户", "warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CustomerAPI.updateInvite({
|
||||||
|
customerId: invitee.id,
|
||||||
|
introducerCustomerId: customer.value.id
|
||||||
|
});
|
||||||
|
await loadCustomer();
|
||||||
|
showToast(`已添加 ${invitee.name || invitee.code}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInviteeSelected(id: string): Promise<void> {
|
||||||
|
selectedInviteeId.value = id;
|
||||||
|
const invitee = await CustomerAPI.detail(id);
|
||||||
|
await addInvitee(invitee);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openScanPopup(): void {
|
||||||
|
pendingInvitee.value = undefined;
|
||||||
|
latestDecodedText = "";
|
||||||
|
scanStatus.value = "正在启动扫码预览";
|
||||||
|
scanPopupVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeScanPopup(): void {
|
||||||
|
scanPopupVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecodedText(rawText: string): Promise<void> {
|
||||||
|
const normalizedText = rawText.trim();
|
||||||
|
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
latestDecodedText = normalizedText;
|
||||||
|
await settingStore.playScanSuccessSound();
|
||||||
|
const matched = await loadCustomerByCode(normalizedText);
|
||||||
|
if (!matched) {
|
||||||
|
scanStatus.value = `已识别 ${normalizedText},未匹配客户档案。`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingInvitee.value = matched;
|
||||||
|
scanStatus.value = `已识别 ${matched.code},请确认是否添加 ${matched.name}。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanStatusChange(value: string): void {
|
||||||
|
scanStatus.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmScannedInvitee(): Promise<void> {
|
||||||
|
if (!pendingInvitee.value) {
|
||||||
|
showToast("请先扫码识别客户", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await addInvitee(pendingInvitee.value)) {
|
||||||
|
closeScanPopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomerByCode(code: string): Promise<Customer | undefined> {
|
||||||
|
return await CustomerAPI.nullableDetailByCode(code) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goCustomerDetail(target: Customer): Promise<void> {
|
||||||
|
if (!target.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await router.push(`/management/customers/${target.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goCustomerDataCharts(): Promise<void> {
|
||||||
|
if (!customer.value?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push(`/management/customers/${customer.value.id}/data-charts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadCustomer();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => route.params.id, async () => {
|
||||||
|
await loadCustomer();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.customer-detail {
|
||||||
|
gap: 1rem;
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.customer {
|
||||||
|
margin: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditioning-part {
|
||||||
|
gap: .5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite {
|
||||||
|
|
||||||
|
.item {
|
||||||
|
|
||||||
|
.title {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
|
||||||
|
:deep(.t-cell:after) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: .875rem;
|
||||||
|
|
||||||
|
&.fallback {
|
||||||
|
color: #2563eb;
|
||||||
|
display: flex;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
background: #dbeafe;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 var(--td-cell-group-title-padding-left, 16px);
|
||||||
|
font-size: var(--td-cell-group-title-font-size, 14px);
|
||||||
|
text-align: left;
|
||||||
|
background: var(--td-cell-group-title-bg-color, var(--td-bg-color-secondarycontainer, var(--td-gray-color-1, #f3f3f3)));
|
||||||
|
align-items: center;
|
||||||
|
line-height: var(--td-cell-group-title-line-height, 45px);
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: var(--td-cell-group-title-color, var(--td-text-color-placeholder, var(--td-font-gray-3, rgba(0, 0, 0, .4))));
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #52607a;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
background: #f7faff;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-actions {
|
||||||
|
gap: .75rem;
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-popup {
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom, 0)) 1rem;
|
||||||
|
display: flex;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 86vh;
|
||||||
|
border-radius: 1.25rem 1.25rem 0 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.customer-detail {
|
||||||
|
|
||||||
|
.profile-main p,
|
||||||
|
.label,
|
||||||
|
.meta,
|
||||||
|
.sex-line,
|
||||||
|
.empty {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-popup {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, .96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
|
||||||
|
&.fallback {
|
||||||
|
color: #93c5fd;
|
||||||
|
background: rgba(30, 64, 175, .32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="savePermissionCode" class="customer-edit">
|
||||||
|
<t-form class="form" label-align="left" label-width="6rem">
|
||||||
|
<t-form-item label="客户编码">
|
||||||
|
<t-input
|
||||||
|
v-model.trim="formModel.code"
|
||||||
|
placeholder="客户编码"
|
||||||
|
borderless
|
||||||
|
>
|
||||||
|
<template #suffix-icon>
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
size="extra-small"
|
||||||
|
@click="toggleScanner('code')"
|
||||||
|
>
|
||||||
|
<span v-text="scannerVisible && scannerTarget === 'code' ? '关闭扫码' : '扫码'" />
|
||||||
|
</t-button>
|
||||||
|
</template>
|
||||||
|
</t-input>
|
||||||
|
</t-form-item>
|
||||||
|
<div v-if="scannerVisible && scannerTarget === 'code'" class="code-scanner-wrapper">
|
||||||
|
<code-scanner
|
||||||
|
visible
|
||||||
|
:status-text="scanStatus"
|
||||||
|
active-text="正在识别客户编码。"
|
||||||
|
@decode="handleDecodedText"
|
||||||
|
@status-change="handleScanStatusChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<t-form-item label="姓名" required-mark>
|
||||||
|
<t-input
|
||||||
|
v-model.trim="formModel.name"
|
||||||
|
placeholder="姓名"
|
||||||
|
borderless
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="年龄">
|
||||||
|
<t-input
|
||||||
|
v-model.trim="formModel.age"
|
||||||
|
type="number"
|
||||||
|
placeholder="年龄"
|
||||||
|
borderless
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="照片">
|
||||||
|
<photo-field
|
||||||
|
v-model="formModel.photoURL"
|
||||||
|
alt-text="客户照片"
|
||||||
|
:preview="false"
|
||||||
|
@add="selectPhoto"
|
||||||
|
@clear="resetPhotoState"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="性别">
|
||||||
|
<t-radio-group class="sex" v-model="formModel.gender" variant="default-filled">
|
||||||
|
<t-radio :block="false" borderless value="MALE">男</t-radio>
|
||||||
|
<t-radio :block="false" borderless value="FEMALE">女</t-radio>
|
||||||
|
<t-radio :block="false" borderless value="">未知</t-radio>
|
||||||
|
</t-radio-group>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="手机号">
|
||||||
|
<t-input v-model.trim="formModel.telephone" placeholder="手机号" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="介绍人">
|
||||||
|
<div class="referrer">
|
||||||
|
<t-input
|
||||||
|
v-model.trim="formModel.referrer"
|
||||||
|
readonly
|
||||||
|
placeholder="选择或扫码"
|
||||||
|
borderless
|
||||||
|
/>
|
||||||
|
<div class="actions">
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
variant="outline"
|
||||||
|
size="extra-small"
|
||||||
|
@click="selectorVisible = true"
|
||||||
|
>选择</t-button>
|
||||||
|
<t-button theme="primary" size="extra-small" @click="toggleScanner('referrer')">
|
||||||
|
<span v-text="scannerVisible && scannerTarget === 'referrer' ? '关闭扫码' : '扫码'" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t-form-item>
|
||||||
|
<div v-if="scannerVisible && scannerTarget === 'referrer'" class="code-scanner-wrapper">
|
||||||
|
<code-scanner
|
||||||
|
visible
|
||||||
|
:status-text="scanStatus"
|
||||||
|
active-text="正在识别介绍人编码。"
|
||||||
|
@decode="handleDecodedText"
|
||||||
|
@status-change="handleScanStatusChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<t-form-item label="住址">
|
||||||
|
<t-textarea
|
||||||
|
class="textarea"
|
||||||
|
v-model.trim="formModel.address"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
placeholder="住址"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="病症">
|
||||||
|
<t-textarea
|
||||||
|
class="textarea"
|
||||||
|
v-model.trim="formModel.disease"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
placeholder="病症"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<div class="conditioning-part">
|
||||||
|
<span class="gray">调理部位</span>
|
||||||
|
<body-area-selector v-model="selectedAreas" />
|
||||||
|
</div>
|
||||||
|
<t-form-item label="备注">
|
||||||
|
<t-textarea
|
||||||
|
class="textarea"
|
||||||
|
v-model.trim="formModel.note"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
placeholder="客户备注"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
<input
|
||||||
|
ref="photoInputRef"
|
||||||
|
class="file-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
@change="handlePhotoSelected"
|
||||||
|
>
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
class="save"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:loading="saving"
|
||||||
|
@click="saveCustomer"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="check" />
|
||||||
|
</template>
|
||||||
|
保存
|
||||||
|
</t-button>
|
||||||
|
<customer-selector
|
||||||
|
v-model="referrerCustomerId"
|
||||||
|
v-model:visible="selectorVisible"
|
||||||
|
@change="handleReferrerChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { AttachmentAPI } from "timi-web";
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import CustomerAPI from "@/api/CustomerAPI";
|
||||||
|
import BodyAreaSelector from "@/components/BodyAreaSelector.vue";
|
||||||
|
import CodeScanner from "@/components/CodeScanner.vue";
|
||||||
|
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||||
|
import PhotoField from "@/components/PhotoField.vue";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import type { BodyAreaValue, Customer, CustomerGender } from "@/types/Customer";
|
||||||
|
import { createBodyAreaValue, getCustomerPhotoURL, normalizeBodyAreaValue } from "@/utils/customer";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
import { compressUploadImage } from "@/utils/imageCompression";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "CustomerEdit"
|
||||||
|
});
|
||||||
|
|
||||||
|
type ScanTarget = "code" | "referrer";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const isCreateMode = computed(() => route.name === "CustomerCreate");
|
||||||
|
const savePermissionCode = computed(() => isCreateMode.value ? gaoPermissionCodes.customer.create : gaoPermissionCodes.customer.update);
|
||||||
|
const selectedAreas = ref<BodyAreaValue>(createBodyAreaValue());
|
||||||
|
const selectorVisible = ref(false);
|
||||||
|
const referrerCustomerId = ref("");
|
||||||
|
const photoInputRef = ref<HTMLInputElement>();
|
||||||
|
const photoTempFileId = ref("");
|
||||||
|
const scannerVisible = ref(false);
|
||||||
|
const scannerTarget = ref<ScanTarget>("code");
|
||||||
|
const scanStatus = ref("点击扫码开始识别。");
|
||||||
|
const currentCustomer = ref<Customer>();
|
||||||
|
const saving = ref(false);
|
||||||
|
const canSubmit = computed(() => !!formModel.name.trim());
|
||||||
|
let latestDecodedText = "";
|
||||||
|
|
||||||
|
const formModel = reactive({
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
gender: "" as CustomerGender | "",
|
||||||
|
photoURL: "",
|
||||||
|
telephone: "",
|
||||||
|
age: "",
|
||||||
|
address: "",
|
||||||
|
referrer: "",
|
||||||
|
disease: "",
|
||||||
|
inviterCustomerId: "",
|
||||||
|
note: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetFormModel(): void {
|
||||||
|
Object.assign(formModel, {
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
gender: "",
|
||||||
|
photoURL: "",
|
||||||
|
telephone: "",
|
||||||
|
age: "",
|
||||||
|
address: "",
|
||||||
|
referrer: "",
|
||||||
|
disease: "",
|
||||||
|
inviterCustomerId: "",
|
||||||
|
note: ""
|
||||||
|
});
|
||||||
|
currentCustomer.value = undefined;
|
||||||
|
selectedAreas.value = createBodyAreaValue();
|
||||||
|
referrerCustomerId.value = "";
|
||||||
|
photoTempFileId.value = "";
|
||||||
|
scannerVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillCreateInviter(): Promise<void> {
|
||||||
|
const inviterCode = String(route.query.inviterCode || "");
|
||||||
|
if (!inviterCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviter = await findCustomer(inviterCode);
|
||||||
|
if (!inviter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formModel.referrer = inviter.name || inviter.code || "";
|
||||||
|
formModel.inviterCustomerId = inviter.id || "";
|
||||||
|
referrerCustomerId.value = inviter.id || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillForm(): Promise<void> {
|
||||||
|
resetFormModel();
|
||||||
|
if (isCreateMode.value) {
|
||||||
|
await fillCreateInviter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = String(route.params.id || "");
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const customer = await CustomerAPI.detail(id);
|
||||||
|
currentCustomer.value = customer;
|
||||||
|
Object.assign(formModel, {
|
||||||
|
code: customer.code || "",
|
||||||
|
name: customer.name || "",
|
||||||
|
gender: customer.gender || "",
|
||||||
|
photoURL: getCustomerPhotoURL(customer),
|
||||||
|
telephone: customer.telephone || "",
|
||||||
|
age: customer.age === undefined || customer.age === null ? "" : String(customer.age),
|
||||||
|
address: customer.address || "",
|
||||||
|
referrer: customer.introducerCustomer?.name || "",
|
||||||
|
disease: customer.disease || "",
|
||||||
|
inviterCustomerId: customer.introducerCustomerId || "",
|
||||||
|
note: customer.remark || ""
|
||||||
|
});
|
||||||
|
selectedAreas.value = normalizeBodyAreaValue(customer.conditioningPart);
|
||||||
|
referrerCustomerId.value = customer.introducerCustomer?.id || "";
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("客户详情加载失败", err);
|
||||||
|
showToast("客户详情加载失败", "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 扫码 ----------
|
||||||
|
|
||||||
|
async function toggleScanner(target: ScanTarget): Promise<void> {
|
||||||
|
if (scannerVisible.value && scannerTarget.value === target) {
|
||||||
|
scannerVisible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scannerTarget.value = target;
|
||||||
|
scannerVisible.value = true;
|
||||||
|
latestDecodedText = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecodedText(rawText: string): Promise<void> {
|
||||||
|
const normalizedText = rawText.trim();
|
||||||
|
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
latestDecodedText = normalizedText;
|
||||||
|
await settingStore.playScanSuccessSound();
|
||||||
|
if (scannerTarget.value === "code") {
|
||||||
|
formModel.code = normalizedText;
|
||||||
|
scanStatus.value = `已识别客户编码 ${normalizedText}。`;
|
||||||
|
scannerVisible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyReferrer(normalizedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanStatusChange(value: string): void {
|
||||||
|
scanStatus.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 照片 ----------
|
||||||
|
|
||||||
|
function selectPhoto(): void {
|
||||||
|
photoInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPhotoState(): void {
|
||||||
|
photoTempFileId.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePhotoSelected(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const compressedFile = await compressUploadImage(file);
|
||||||
|
const [tempFile] = await AttachmentAPI.uploadTemp(compressedFile);
|
||||||
|
if (!tempFile?.id) {
|
||||||
|
showToast("客户照片上传失败", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
photoTempFileId.value = tempFile.id;
|
||||||
|
formModel.photoURL = AttachmentAPI.getTempReadURL(tempFile.id);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 介绍人 ----------
|
||||||
|
|
||||||
|
async function findCustomer(rawValue: string): Promise<Customer | undefined> {
|
||||||
|
const normalizedText = rawValue.trim();
|
||||||
|
if (!normalizedText) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await CustomerAPI.nullableDetailByCode(normalizedText);
|
||||||
|
if (customer) {
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await CustomerAPI.search({ keyword: normalizedText });
|
||||||
|
return list[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyReferrer(rawValue: string): Promise<void> {
|
||||||
|
const normalizedText = rawValue.trim();
|
||||||
|
if (!normalizedText) {
|
||||||
|
formModel.referrer = "";
|
||||||
|
formModel.inviterCustomerId = "";
|
||||||
|
referrerCustomerId.value = "";
|
||||||
|
scannerVisible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = await findCustomer(normalizedText);
|
||||||
|
if (!matched?.id) {
|
||||||
|
formModel.referrer = "";
|
||||||
|
formModel.inviterCustomerId = "";
|
||||||
|
referrerCustomerId.value = "";
|
||||||
|
scanStatus.value = `未匹配到介绍人 ${normalizedText},请重新选择或扫码。`;
|
||||||
|
scannerVisible.value = false;
|
||||||
|
showToast("介绍人不存在,请选择或扫码已有客户", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formModel.referrer = matched.name || matched.code || "";
|
||||||
|
formModel.inviterCustomerId = matched.id;
|
||||||
|
referrerCustomerId.value = matched.id;
|
||||||
|
scanStatus.value = `已匹配介绍人 ${formModel.referrer}。`;
|
||||||
|
scannerVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReferrerChange(id: string): Promise<void> {
|
||||||
|
const customer = await CustomerAPI.detail(id);
|
||||||
|
formModel.referrer = customer.name || customer.code || "";
|
||||||
|
formModel.inviterCustomerId = customer.id || "";
|
||||||
|
referrerCustomerId.value = customer.id || "";
|
||||||
|
scanStatus.value = `已选择介绍人 ${formModel.referrer}。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 保存 ----------
|
||||||
|
|
||||||
|
async function saveCustomer(): Promise<void> {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerCode = formModel.code.trim();
|
||||||
|
const payload: Customer = {
|
||||||
|
...(currentCustomer.value || {}),
|
||||||
|
code: customerCode || undefined,
|
||||||
|
name: formModel.name,
|
||||||
|
gender: formModel.gender || undefined,
|
||||||
|
age: Number(formModel.age) || undefined,
|
||||||
|
telephone: formModel.telephone,
|
||||||
|
address: formModel.address,
|
||||||
|
remark: formModel.note,
|
||||||
|
disease: formModel.disease,
|
||||||
|
conditioningPart: selectedAreas.value,
|
||||||
|
introducerCustomerId: formModel.inviterCustomerId || undefined,
|
||||||
|
attachmentIdList: photoTempFileId.value ? [photoTempFileId.value] : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
let savedCustomer = currentCustomer.value;
|
||||||
|
if (isCreateMode.value) {
|
||||||
|
savedCustomer = await CustomerAPI.create(payload);
|
||||||
|
} else {
|
||||||
|
await CustomerAPI.update(payload);
|
||||||
|
}
|
||||||
|
showToast("客户已保存");
|
||||||
|
const redirect = String(route.query.redirect || "");
|
||||||
|
if (redirect) {
|
||||||
|
await router.replace(redirect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (savedCustomer?.id) {
|
||||||
|
await router.replace(`/management/customers/${savedCustomer.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await router.replace("/management/customers");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(): boolean {
|
||||||
|
if (!formModel.name.trim()) {
|
||||||
|
showToast("请填写客户姓名", "warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||||
|
Toast({
|
||||||
|
theme,
|
||||||
|
direction: "row",
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => formModel.referrer, (value) => {
|
||||||
|
if (!value.trim()) {
|
||||||
|
formModel.inviterCustomerId = "";
|
||||||
|
referrerCustomerId.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fillForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => route.fullPath, async () => {
|
||||||
|
await fillForm();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.customer-edit {
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: .5rem 0;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
|
||||||
|
.code-scanner-wrapper {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referrer {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sex {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conditioning-part {
|
||||||
|
gap: .5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.customer-edit {
|
||||||
|
|
||||||
|
:deep(.form-item .t-form__label) {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.textarea .t-textarea__inner) {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, .72);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="pageRef" v-can="gaoPermissionCodes.customer.read" class="customer-list">
|
||||||
|
<div class="search-bar">
|
||||||
|
<t-input
|
||||||
|
v-model.trim="keyword"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="搜索编码、姓名、电话"
|
||||||
|
clearable
|
||||||
|
borderless
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<section class="total">
|
||||||
|
<span class="gray">客户总数:</span>
|
||||||
|
<span class="bold" v-text="String(totalCount)" />
|
||||||
|
</section>
|
||||||
|
<section class="items">
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
v-for="item in customerList"
|
||||||
|
:key="item.id || item.code"
|
||||||
|
@click="goDetail(item)"
|
||||||
|
>
|
||||||
|
<t-image v-if="getCustomerPhotoURL(item)" :src="getCustomerPhotoURL(item)" :alt="item.name" fit="cover" class="avatar" />
|
||||||
|
<div v-else class="avatar fallback" v-text="(item.name || item.code || '客').slice(0, 1)" />
|
||||||
|
<div class="content">
|
||||||
|
<div class="line">
|
||||||
|
<div class="name">
|
||||||
|
<h4 class="value" v-text="item.name" />
|
||||||
|
<gender-icon :value="item.gender" size="1.125rem" />
|
||||||
|
</div>
|
||||||
|
<span class="meta gray" v-text="item.code" />
|
||||||
|
</div>
|
||||||
|
<div class="line">
|
||||||
|
<div class="gray">
|
||||||
|
<template v-if="item.telephone">
|
||||||
|
<t-icon name="call" />
|
||||||
|
<span class="word-space" v-text="item.telephone" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<t-tag
|
||||||
|
size="small"
|
||||||
|
variant="outline"
|
||||||
|
theme="success"
|
||||||
|
:content="`${item.invitedCount} 位邀请`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!customerList.length" class="empty-block" v-text="loading ? '正在加载客户' : '暂无客户数据'" />
|
||||||
|
<div v-if="customerList.length" class="load-state" v-text="loading ? '正在加载更多' : noMore ? '已经到底了' : '上拉加载更多'" />
|
||||||
|
</section>
|
||||||
|
<div class="bottom-actions">
|
||||||
|
<t-button
|
||||||
|
v-can="gaoPermissionCodes.customer.read"
|
||||||
|
theme="primary"
|
||||||
|
variant="outline"
|
||||||
|
class="export bg-white"
|
||||||
|
:loading="exporting"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="exportCustomers"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="download" />
|
||||||
|
</template>
|
||||||
|
导出
|
||||||
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
v-can="gaoPermissionCodes.customer.create"
|
||||||
|
theme="primary"
|
||||||
|
class="create"
|
||||||
|
@click="router.push('/management/customers/create')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="add" />
|
||||||
|
</template>
|
||||||
|
添加
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { Scroller, Time, Toolkit } from "timi-web";
|
||||||
|
import CustomerAPI from "@/api/CustomerAPI";
|
||||||
|
import GenderIcon from "@/components/GenderIcon.vue";
|
||||||
|
import type { Customer, CustomerSearchReq } from "@/types/Customer";
|
||||||
|
import { getCustomerPhotoURL } from "@/utils/customer";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "CustomerList"
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollListenerName = "CustomerList";
|
||||||
|
const router = useRouter();
|
||||||
|
const pageSize = 20;
|
||||||
|
const keyword = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const exporting = ref(false);
|
||||||
|
const noMore = ref(false);
|
||||||
|
const pageIndex = ref(0);
|
||||||
|
const totalCount = ref(0);
|
||||||
|
const customerList = ref<Customer[]>([]);
|
||||||
|
const pageRef = ref<HTMLElement>();
|
||||||
|
let loadToken = 0;
|
||||||
|
let keywordTimer: number | undefined;
|
||||||
|
|
||||||
|
function getPageScrollElement(): HTMLElement | null {
|
||||||
|
return pageRef.value?.closest(".pages") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadCustomers(): Promise<void> {
|
||||||
|
pageIndex.value = 0;
|
||||||
|
totalCount.value = 0;
|
||||||
|
customerList.value = [];
|
||||||
|
noMore.value = false;
|
||||||
|
await loadCustomers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomers(): Promise<void> {
|
||||||
|
if (loading.value || noMore.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = loadToken + 1;
|
||||||
|
loadToken = token;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await CustomerAPI.searchPage({
|
||||||
|
index: pageIndex.value,
|
||||||
|
size: pageSize,
|
||||||
|
equalsExample: buildSearchReq()
|
||||||
|
});
|
||||||
|
if (token !== loadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customerList.value = customerList.value.concat(result.list || []);
|
||||||
|
totalCount.value = result.total || 0;
|
||||||
|
pageIndex.value += 1;
|
||||||
|
noMore.value = customerList.value.length >= totalCount.value || (result.list || []).length < pageSize;
|
||||||
|
} finally {
|
||||||
|
if (token === loadToken) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCustomers(): Promise<void> {
|
||||||
|
if (exporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exporting.value = true;
|
||||||
|
try {
|
||||||
|
const blob = await CustomerAPI.exportExcel(buildSearchReq());
|
||||||
|
Toolkit.downloadBlob(blob, `客户列表-${Time.formatDate(new Date())}.xlsx`);
|
||||||
|
showToast("导出已开始");
|
||||||
|
} finally {
|
||||||
|
exporting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goDetail(customer: Customer): Promise<void> {
|
||||||
|
if (!customer.id) {
|
||||||
|
showToast("客户 ID 缺失,无法打开详情", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await router.push(`/management/customers/${customer.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchReq(): CustomerSearchReq {
|
||||||
|
return {
|
||||||
|
keyword: keyword.value || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||||
|
Toast({
|
||||||
|
message,
|
||||||
|
theme
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(keyword, async () => {
|
||||||
|
window.clearTimeout(keywordTimer);
|
||||||
|
keywordTimer = window.setTimeout(async () => {
|
||||||
|
await reloadCustomers();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await reloadCustomers();
|
||||||
|
Scroller.addBottomListener(scrollListenerName, loadCustomers, {
|
||||||
|
canLoad: () => !loading.value && !noMore.value,
|
||||||
|
getElement: getPageScrollElement
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.clearTimeout(keywordTimer);
|
||||||
|
Scroller.removeListener(scrollListenerName);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.customer-list {
|
||||||
|
padding: calc(var(--app-nav-height)) 0 5.5rem 0;
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
top: var(--app-nav-height);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
position: sticky;
|
||||||
|
background: #F5F5F8;
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(148, 163, 184, .28);
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
background: #f8fbff;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
gap: .5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
gap: .75rem;
|
||||||
|
padding: .5rem;
|
||||||
|
display: flex;
|
||||||
|
text-align: left;
|
||||||
|
background: #f7faff;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--td-border-level-1-color);
|
||||||
|
border-radius: .875rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex: none;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
|
||||||
|
&.fallback {
|
||||||
|
color: #2563eb;
|
||||||
|
display: flex;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
background: #dbeafe;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
gap: .75rem;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
gap: .375rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.value {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block,
|
||||||
|
.load-state {
|
||||||
|
padding: 1rem;
|
||||||
|
color: #52607a;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8fbff;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-actions {
|
||||||
|
gap: .75rem;
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
|
||||||
|
.export {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.customer-list {
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
background: #10161f;
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, .72);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
|
||||||
|
.load-state,
|
||||||
|
.item,
|
||||||
|
.empty-block {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(30, 41, 59, .9);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
|
||||||
|
&.fallback {
|
||||||
|
color: #93c5fd;
|
||||||
|
background: rgba(30, 64, 175, .32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
background: rgba(30, 41, 59, .9);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="gaoPermissionCodes.stat.read" class="data-charts-page">
|
||||||
|
<section class="summary">
|
||||||
|
<div v-for="item in summaryList" :key="item.label" class="summary-item">
|
||||||
|
<span v-text="item.label" />
|
||||||
|
<strong v-text="item.value" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<registration-event-record-calendar
|
||||||
|
v-model="monthCursor"
|
||||||
|
v-model:selected-registration-event-id-list="selectedRegistrationEventIdList"
|
||||||
|
:registration-event-list="registrationEventList"
|
||||||
|
empty-text="当前月份暂无登记事件记录"
|
||||||
|
:loading="calendarLoading"
|
||||||
|
:records="monthRecords"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="item in chartList"
|
||||||
|
:key="item.id"
|
||||||
|
class="chart-section"
|
||||||
|
>
|
||||||
|
<h2 v-text="item.title" />
|
||||||
|
<div :ref="element => setChartRef(item.id, element)" class="chart" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ECharts, EChartsOption } from "echarts";
|
||||||
|
import { BarChart, LineChart } from "echarts/charts";
|
||||||
|
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||||
|
import { init, use } from "echarts/core";
|
||||||
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
|
import CustomerAPI from "@/api/CustomerAPI";
|
||||||
|
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||||
|
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
|
||||||
|
import RegistrationEventRecordCalendar from "@/components/RegistrationEventRecordCalendar.vue";
|
||||||
|
import type { CustomerDailyStatView, CustomerGenderStatView } from "@/types/Customer";
|
||||||
|
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||||
|
import type { RegistrationEventRecord, RegistrationEventRecordCalendarView, RegistrationEventRecordDailyStatView } from "@/types/RegistrationEventRecord";
|
||||||
|
import { Time } from "timi-web";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
interface SummaryItem {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartProfile {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
option: EChartsOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ManagementDataCharts"
|
||||||
|
});
|
||||||
|
|
||||||
|
use([
|
||||||
|
BarChart,
|
||||||
|
LineChart,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
CanvasRenderer
|
||||||
|
]);
|
||||||
|
|
||||||
|
const chartRefs = new Map<string, HTMLElement>();
|
||||||
|
const chartInstances = new Map<string, ECharts>();
|
||||||
|
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||||
|
const selectedRegistrationEventIdList = ref<string[]>([]);
|
||||||
|
const customerDailyStatList = ref<CustomerDailyStatView[]>([]);
|
||||||
|
const customerGenderStatList = ref<CustomerGenderStatView[]>([]);
|
||||||
|
const recordDailyStatMap = ref<Record<string, RegistrationEventRecordDailyStatView[]>>({});
|
||||||
|
const monthRecords = ref<RegistrationEventRecord[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const calendarLoading = ref(false);
|
||||||
|
const monthCursor = ref(getMonthStart(new Date()));
|
||||||
|
const beginDate = getDateStart(addDays(new Date(), -6));
|
||||||
|
const endDate = getDateEnd(new Date());
|
||||||
|
const dateTextList = buildDateTextList(beginDate, endDate);
|
||||||
|
const timestampRange = {
|
||||||
|
beginRegisteredAt: Time.toTimestamp(beginDate),
|
||||||
|
endRegisteredAt: Time.toTimestamp(endDate)
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryList = computed<SummaryItem[]>(() => [
|
||||||
|
{
|
||||||
|
label: "当前客户",
|
||||||
|
value: `${getLatestTotalCustomerCount()} 人`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "本周新增",
|
||||||
|
value: `${sumCustomerNewCount()} 人`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "本周登记",
|
||||||
|
value: `${sumActionDailyCount()} 次`
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const chartList = computed<ChartProfile[]>(() => [
|
||||||
|
{
|
||||||
|
id: "customer-growth",
|
||||||
|
title: "客户增长趋势",
|
||||||
|
option: buildCustomerGrowthOption()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "record-trend",
|
||||||
|
title: "登记量趋势",
|
||||||
|
option: buildRecordTrendOption()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "customer-value",
|
||||||
|
title: "事件结构",
|
||||||
|
option: buildEventStructureOption()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "customer-gender",
|
||||||
|
title: "性别统计",
|
||||||
|
option: buildGenderStatOption()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
watch(chartList, async () => {
|
||||||
|
await nextTick();
|
||||||
|
renderCharts();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(monthCursor, async () => {
|
||||||
|
await loadMonthRecords();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadChartsData();
|
||||||
|
await loadMonthRecords();
|
||||||
|
await nextTick();
|
||||||
|
renderCharts();
|
||||||
|
window.addEventListener("resize", resizeCharts);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("resize", resizeCharts);
|
||||||
|
chartInstances.forEach(item => item.dispose());
|
||||||
|
chartInstances.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setChartRef(id: string, element: Element | ComponentPublicInstance | null): void {
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
chartRefs.set(id, element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCharts(): void {
|
||||||
|
for (const item of chartList.value) {
|
||||||
|
const element = chartRefs.get(item.id);
|
||||||
|
if (!element) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chart = getChartInstance(item.id, element);
|
||||||
|
chart.setOption(item.option, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartInstance(id: string, element: HTMLElement): ECharts {
|
||||||
|
const instance = chartInstances.get(id);
|
||||||
|
if (instance) {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chart = init(element);
|
||||||
|
chartInstances.set(id, chart);
|
||||||
|
return chart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCharts(): void {
|
||||||
|
chartInstances.forEach(item => item.resize());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChartsData(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
registrationEventList.value = await RegistrationEventAPI.enabledList();
|
||||||
|
const validRegistrationEventList = registrationEventList.value.filter((item): item is RegistrationEvent & { id: string } => Boolean(item.id));
|
||||||
|
selectedRegistrationEventIdList.value = validRegistrationEventList.map(item => item.id);
|
||||||
|
const [customerStatList, genderStatList, recordDailyStatList] = await Promise.all([
|
||||||
|
CustomerAPI.dailyStat({
|
||||||
|
beginCreatedAt: timestampRange.beginRegisteredAt,
|
||||||
|
endCreatedAt: timestampRange.endRegisteredAt
|
||||||
|
}),
|
||||||
|
CustomerAPI.genderStat(),
|
||||||
|
Promise.all(validRegistrationEventList.map(async item => ({
|
||||||
|
registrationEventId: item.id,
|
||||||
|
stats: await RegistrationEventRecordAPI.allCustomerRegistrationEventDailyStat({
|
||||||
|
registrationEventId: item.id,
|
||||||
|
beginRegisteredAt: timestampRange.beginRegisteredAt,
|
||||||
|
endRegisteredAt: timestampRange.endRegisteredAt
|
||||||
|
})
|
||||||
|
})))
|
||||||
|
]);
|
||||||
|
customerDailyStatList.value = customerStatList;
|
||||||
|
customerGenderStatList.value = genderStatList;
|
||||||
|
recordDailyStatMap.value = Object.fromEntries(recordDailyStatList.map(item => [item.registrationEventId, item.stats]));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMonthRecords(): Promise<void> {
|
||||||
|
const begin = getCalendarBeginDate(monthCursor.value);
|
||||||
|
const end = getDateEnd(addDays(begin, 41));
|
||||||
|
calendarLoading.value = true;
|
||||||
|
try {
|
||||||
|
const calendarRecordList = await RegistrationEventRecordAPI.calendarRangeList({
|
||||||
|
beginRegisteredAt: Time.toTimestamp(begin),
|
||||||
|
endRegisteredAt: Time.toTimestamp(end)
|
||||||
|
});
|
||||||
|
monthRecords.value = calendarRecordList.map(buildCalendarRecord);
|
||||||
|
} finally {
|
||||||
|
calendarLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBaseOption(): EChartsOption {
|
||||||
|
return {
|
||||||
|
color: ["#2563eb", "#0f766e", "#d97706", "#db2777", "#7c3aed"],
|
||||||
|
grid: {
|
||||||
|
top: 48,
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
bottom: 12,
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
top: 0,
|
||||||
|
icon: "roundRect",
|
||||||
|
textStyle: {
|
||||||
|
color: "#52607a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis"
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: dateTextList.map(item => item.slice(5)),
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: "#52607a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: "#52607a"
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: "rgba(148, 163, 184, .2)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCustomerGrowthOption(): EChartsOption {
|
||||||
|
return {
|
||||||
|
...buildBaseOption(),
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "客户总量",
|
||||||
|
type: "line",
|
||||||
|
smooth: true,
|
||||||
|
data: dateTextList.map(dateText => getCustomerDailyStat(dateText)?.totalCustomerCount || 0)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "新客户量",
|
||||||
|
type: "bar",
|
||||||
|
data: dateTextList.map(dateText => getCustomerDailyStat(dateText)?.newCustomerCount || 0)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecordTrendOption(): EChartsOption {
|
||||||
|
return {
|
||||||
|
...buildBaseOption(),
|
||||||
|
series: registrationEventList.value.map(action => ({
|
||||||
|
name: action.name || "未命名事件",
|
||||||
|
type: "bar",
|
||||||
|
stack: "record",
|
||||||
|
itemStyle: getRegistrationEventColor(action) ? {
|
||||||
|
color: getRegistrationEventColor(action)
|
||||||
|
} : undefined,
|
||||||
|
data: dateTextList.map(dateText => countRecordByRegistrationEventAndDate(action.id || "", dateText))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEventStructureOption(): EChartsOption {
|
||||||
|
return {
|
||||||
|
...buildBaseOption(),
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: registrationEventList.value.map(item => item.name || "未命名事件"),
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: "#52607a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
name: "登记次数",
|
||||||
|
type: "bar",
|
||||||
|
data: registrationEventList.value.map(item => ({
|
||||||
|
value: countRecordByRegistrationEvent(item.id || ""),
|
||||||
|
itemStyle: getRegistrationEventColor(item) ? {
|
||||||
|
color: getRegistrationEventColor(item)
|
||||||
|
} : undefined
|
||||||
|
}))
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGenderStatOption(): EChartsOption {
|
||||||
|
return {
|
||||||
|
...buildBaseOption(),
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: ["男", "女", "未知"],
|
||||||
|
axisTick: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: "#52607a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
name: "客户数量",
|
||||||
|
type: "bar",
|
||||||
|
data: [
|
||||||
|
getGenderCount("MALE"),
|
||||||
|
getGenderCount("FEMALE"),
|
||||||
|
getGenderCount("UNKNOWN")
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestTotalCustomerCount(): number {
|
||||||
|
const latest = customerDailyStatList.value[customerDailyStatList.value.length - 1];
|
||||||
|
return latest?.totalCustomerCount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumCustomerNewCount(): number {
|
||||||
|
return customerDailyStatList.value.reduce((sum, item) => sum + (item.newCustomerCount || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumActionDailyCount(): number {
|
||||||
|
return registrationEventList.value.reduce((sum, item) => sum + countRecordByRegistrationEvent(item.id || ""), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomerDailyStat(dateText: string): CustomerDailyStatView | undefined {
|
||||||
|
const dateValue = Number(dateText.replaceAll("-", ""));
|
||||||
|
return customerDailyStatList.value.find(item => item.dateValue === dateValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countRecordByRegistrationEventAndDate(registrationEventId: string, dateText: string): number {
|
||||||
|
const dateValue = Number(dateText.replaceAll("-", ""));
|
||||||
|
return recordDailyStatMap.value[registrationEventId]?.find(item => item.dateValue === dateValue)?.dailyCount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countRecordByRegistrationEvent(registrationEventId: string): number {
|
||||||
|
return (recordDailyStatMap.value[registrationEventId] || []).reduce((sum, item) => sum + (item.dailyCount || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegistrationEventColor(registrationEvent: RegistrationEvent): string | undefined {
|
||||||
|
return registrationEvent.color || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGenderCount(genderCode: "MALE" | "FEMALE" | "UNKNOWN"): number {
|
||||||
|
return customerGenderStatList.value.find(item => item.genderCode === genderCode)?.count || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCalendarRecord(record: RegistrationEventRecordCalendarView): RegistrationEventRecord {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
customerId: record.customerId,
|
||||||
|
registrationEventId: record.registrationEventId,
|
||||||
|
registeredAt: record.registeredAt,
|
||||||
|
customer: record.customerId || record.customerCode || record.customerName ? {
|
||||||
|
id: record.customerId,
|
||||||
|
code: record.customerCode,
|
||||||
|
name: record.customerName
|
||||||
|
} : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateStart(date: Date): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthStart(date: Date): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarBeginDate(date: Date): Date {
|
||||||
|
const monthStart = getMonthStart(date);
|
||||||
|
return addDays(monthStart, -monthStart.getDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateEnd(date: Date): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number): Date {
|
||||||
|
const value = new Date(date);
|
||||||
|
value.setDate(value.getDate() + days);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDateTextList(begin: Date, end: Date): string[] {
|
||||||
|
const list: string[] = [];
|
||||||
|
for (let item = getDateStart(begin); item <= end; item = addDays(item, 1)) {
|
||||||
|
list.push(Time.formatDate(item));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.data-charts-page {
|
||||||
|
gap: 1rem;
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 1rem 1rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
gap: .75rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
padding: .875rem .625rem;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
margin-top: .375rem;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-section {
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 .875rem 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 16rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.data-charts-page {
|
||||||
|
.summary {
|
||||||
|
.summary-item {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-section {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="password-page">
|
||||||
|
<t-form class="form" label-align="left">
|
||||||
|
<t-form-item label="旧密码">
|
||||||
|
<t-input v-model.trim="formModel.oldPassword" type="password" placeholder="旧密码" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="新密码">
|
||||||
|
<t-input v-model.trim="formModel.newPassword" type="password" placeholder="新密码" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="确认密码">
|
||||||
|
<t-input v-model.trim="formModel.confirmPassword" type="password" placeholder="确认密码" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
<t-button class="save" theme="primary" :loading="isSaving" @click="savePassword">保存</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { UserAPI, useUserStore } from "timi-web";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ManagementPassword"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const formModel = reactive({
|
||||||
|
oldPassword: "",
|
||||||
|
newPassword: "",
|
||||||
|
confirmPassword: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
async function savePassword(): Promise<void> {
|
||||||
|
if (!formModel.oldPassword || !formModel.newPassword) {
|
||||||
|
Toast({
|
||||||
|
theme: "warning",
|
||||||
|
direction: "row",
|
||||||
|
message: "请填写旧密码和新密码"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formModel.newPassword !== formModel.confirmPassword) {
|
||||||
|
Toast({
|
||||||
|
theme: "warning",
|
||||||
|
direction: "row",
|
||||||
|
message: "两次输入的新密码不一致"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
await UserAPI.updatePassword({
|
||||||
|
oldValue: formModel.oldPassword,
|
||||||
|
newValue: formModel.newPassword
|
||||||
|
});
|
||||||
|
userStore.clearLoginUser();
|
||||||
|
Toast({
|
||||||
|
theme: "success",
|
||||||
|
direction: "row",
|
||||||
|
message: "密码已更新,请重新登录"
|
||||||
|
});
|
||||||
|
await router.replace("/login");
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.password-page {
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: .5rem 0;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.password-page {
|
||||||
|
.form {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
<template>
|
||||||
|
<div class="profile-page">
|
||||||
|
<t-form class="form" label-align="left">
|
||||||
|
<t-form-item label="头像">
|
||||||
|
<photo-field
|
||||||
|
v-model="formModel.avatar"
|
||||||
|
add-text="更换"
|
||||||
|
alt-text="用户头像"
|
||||||
|
size="compact"
|
||||||
|
:preview="false"
|
||||||
|
:show-remove="false"
|
||||||
|
@add="changeAvatar"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="用户名">
|
||||||
|
<t-input v-model.trim="formModel.name" placeholder="用户名" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="昵称">
|
||||||
|
<t-input v-model.trim="formModel.nickname" placeholder="昵称" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="性别">
|
||||||
|
<t-radio-group class="sex" v-model="formModel.gender" variant="default-filled">
|
||||||
|
<t-radio :block="false" borderless value="男">男</t-radio>
|
||||||
|
<t-radio :block="false" borderless value="女">女</t-radio>
|
||||||
|
<t-radio :block="false" borderless value="未知">未知</t-radio>
|
||||||
|
</t-radio-group>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="手机号">
|
||||||
|
<t-input :model-value="formModel.mobile" readonly borderless />
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
<input
|
||||||
|
ref="avatarInputRef"
|
||||||
|
class="avatar-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
@change="handleAvatarSelected"
|
||||||
|
>
|
||||||
|
<t-cell-group theme="card">
|
||||||
|
<t-cell title="修改密码" arrow hover @click="goPassword" />
|
||||||
|
</t-cell-group>
|
||||||
|
<t-button class="save" theme="primary" :loading="isSaving" @click="saveProfile">保存</t-button>
|
||||||
|
<t-button class="logout bg-white" theme="danger" variant="outline" @click="confirmLogout">退出登录</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
|
||||||
|
import { AttachmentAPI, UserAPI, useUserStore } from "timi-web";
|
||||||
|
import PhotoField from "@/components/PhotoField.vue";
|
||||||
|
import { compressUploadImage } from "@/utils/imageCompression";
|
||||||
|
|
||||||
|
type UserAvatarSource = NonNullable<Parameters<typeof UserAPI.getAvatarURL>[0]> & {
|
||||||
|
name?: string;
|
||||||
|
nick?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ManagementProfile"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const avatarInputRef = ref<HTMLInputElement>();
|
||||||
|
const avatarTempFileId = ref("");
|
||||||
|
const isSaving = ref(false);
|
||||||
|
|
||||||
|
const formModel = reactive({
|
||||||
|
id: "",
|
||||||
|
name: "",
|
||||||
|
avatar: "",
|
||||||
|
nickname: "",
|
||||||
|
role: "",
|
||||||
|
gender: "未知",
|
||||||
|
mobile: "",
|
||||||
|
password: "",
|
||||||
|
office: "",
|
||||||
|
scope: "",
|
||||||
|
status: "",
|
||||||
|
lastLoginAt: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
function fillForm(): void {
|
||||||
|
const user = userStore.loginUser.user;
|
||||||
|
Object.assign(formModel, {
|
||||||
|
id: user?.id || "",
|
||||||
|
name: user?.name || "",
|
||||||
|
avatar: buildUserAvatar(user),
|
||||||
|
nickname: user?.nick || "",
|
||||||
|
role: formatRoleCode(userStore.getRoleList()[0]),
|
||||||
|
gender: sexToText(user?.sex),
|
||||||
|
mobile: user?.phoneNo || "",
|
||||||
|
password: "",
|
||||||
|
office: "",
|
||||||
|
scope: "",
|
||||||
|
status: "",
|
||||||
|
lastLoginAt: user?.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeAvatar(): void {
|
||||||
|
avatarInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAvatarSelected(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const compressedFile = await compressUploadImage(file);
|
||||||
|
const [tempFile] = await AttachmentAPI.uploadTemp(compressedFile);
|
||||||
|
if (!tempFile?.id) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
direction: "row",
|
||||||
|
message: "头像上传失败"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
avatarTempFileId.value = tempFile.id;
|
||||||
|
formModel.avatar = AttachmentAPI.getTempReadURL(tempFile.id);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goPassword(): Promise<void> {
|
||||||
|
await router.push("/management/profile/password");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile(): Promise<void> {
|
||||||
|
if (!formModel.name) {
|
||||||
|
Toast({
|
||||||
|
theme: "warning",
|
||||||
|
direction: "row",
|
||||||
|
message: "请填写用户名"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
const result = await UserAPI.updateCurrent({
|
||||||
|
id: formModel.id,
|
||||||
|
name: formModel.name,
|
||||||
|
nick: formModel.nickname,
|
||||||
|
sex: textToSex(formModel.gender),
|
||||||
|
tempFileIdList: avatarTempFileId.value ? [avatarTempFileId.value] : undefined
|
||||||
|
});
|
||||||
|
await userStore.updateLoginUser(result);
|
||||||
|
Toast({
|
||||||
|
theme: "success",
|
||||||
|
direction: "row",
|
||||||
|
message: "资料已保存"
|
||||||
|
});
|
||||||
|
await router.back();
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmLogout(): Promise<void> {
|
||||||
|
const dialog = DialogPlugin.confirm({
|
||||||
|
title: "退出登录",
|
||||||
|
content: "确定要退出当前账号吗?",
|
||||||
|
cancelBtn: "取消",
|
||||||
|
confirmBtn: "退出",
|
||||||
|
onCancel() {
|
||||||
|
dialog.destroy();
|
||||||
|
},
|
||||||
|
async onConfirm() {
|
||||||
|
dialog.destroy();
|
||||||
|
await logout();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout(): Promise<void> {
|
||||||
|
await userStore.logout();
|
||||||
|
await router.replace("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sexToText(sex?: number): "男" | "女" | "未知" {
|
||||||
|
if (sex === 1) {
|
||||||
|
return "男";
|
||||||
|
}
|
||||||
|
if (sex === 2) {
|
||||||
|
return "女";
|
||||||
|
}
|
||||||
|
return "未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoleCode(roleCode?: string): string {
|
||||||
|
if (!roleCode) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const codePartList = roleCode.split(":");
|
||||||
|
return codePartList[codePartList.length - 1] || roleCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToSex(text: string): number {
|
||||||
|
if (text === "男") {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (text === "女") {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackAvatar(name: string): string {
|
||||||
|
const text = encodeURIComponent((name.trim() || "用户").slice(0, 2));
|
||||||
|
return [
|
||||||
|
"data:image/svg+xml;charset=UTF-8,",
|
||||||
|
"%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E",
|
||||||
|
"%3Crect width='120' height='120' rx='24' fill='%230f766e'/%3E",
|
||||||
|
"%3Ctext x='50%25' y='54%25' fill='white' font-size='36' font-family='Arial,sans-serif' text-anchor='middle' dominant-baseline='middle'%3E",
|
||||||
|
text,
|
||||||
|
"%3C/text%3E%3C/svg%3E"
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserAvatar(user?: UserAvatarSource): string {
|
||||||
|
const name = user?.nick || user?.name || "用户";
|
||||||
|
return UserAPI.getAvatarURL(user) || buildFallbackAvatar(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fillForm();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.profile-page {
|
||||||
|
gap: 1rem;
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: .5rem 0;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
|
||||||
|
.sex {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save,
|
||||||
|
.logout {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
bottom: calc(3.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.profile-page {
|
||||||
|
.form {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="gaoPermissionCodes.qrCode.create" class="qr-page">
|
||||||
|
<t-form class="form" label-align="left">
|
||||||
|
<t-form-item label="编码前缀">
|
||||||
|
<t-input v-model.trim="formModel.prefix" placeholder="如 GAO" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="起始编号">
|
||||||
|
<t-input
|
||||||
|
:model-value="formModel.start"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
borderless
|
||||||
|
@change="value => handleNumberChange('start', value)"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="编号位数" contentAlign="right">
|
||||||
|
<t-stepper
|
||||||
|
v-model="formModel.digits"
|
||||||
|
:min="startMinDigits"
|
||||||
|
:max="maxDigits"
|
||||||
|
default-value="3"
|
||||||
|
theme="outline"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="生成数量">
|
||||||
|
<t-input
|
||||||
|
:model-value="formModel.quantity"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
borderless
|
||||||
|
@change="value => handleNumberChange('quantity', value)"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
<t-cell-group theme="card">
|
||||||
|
<t-cell title="最终起始编码" :note="finalStartCode" />
|
||||||
|
<t-cell title="最终结束编码" :note="finalEndCode" />
|
||||||
|
</t-cell-group>
|
||||||
|
|
||||||
|
<section class="preview">
|
||||||
|
<div class="header">
|
||||||
|
<h2 class="title">预览</h2>
|
||||||
|
<p class="tips" v-text="previewSummary" />
|
||||||
|
</div>
|
||||||
|
<div class="paper" :style="paperStyle">
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
v-for="item in visiblePreviewPages[0].codes"
|
||||||
|
:key="item"
|
||||||
|
>
|
||||||
|
<div class="qr-code" />
|
||||||
|
<div class="text-mark bg-gray" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="actions">
|
||||||
|
<t-button class="bg-white" theme="primary" variant="outline" block :loading="printing" @click="printQrCode">打印</t-button>
|
||||||
|
<t-button theme="primary" block :loading="downloading" @click="downloadImages">
|
||||||
|
<span v-text="downloadText" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import {
|
||||||
|
calculateManagementQrCodeGrid,
|
||||||
|
downloadManagementQrCodeImages,
|
||||||
|
printManagementQrCodeImages,
|
||||||
|
splitManagementQrCodePages
|
||||||
|
} from "@/utils/managementQrCode";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ManagementQrCode"
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ManagementQrCodeFormModel {
|
||||||
|
prefix: string;
|
||||||
|
start: string;
|
||||||
|
digits: number;
|
||||||
|
quantity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minDigits = 1;
|
||||||
|
const maxDigits = 16;
|
||||||
|
const defaultDigits = 3;
|
||||||
|
const storageKey = "management-qr-code-config";
|
||||||
|
|
||||||
|
const formModel = reactive<ManagementQrCodeFormModel>({
|
||||||
|
prefix: "GAO",
|
||||||
|
start: "1",
|
||||||
|
digits: defaultDigits,
|
||||||
|
quantity: "22"
|
||||||
|
});
|
||||||
|
const downloading = ref(false);
|
||||||
|
const printing = ref(false);
|
||||||
|
|
||||||
|
const codeList = computed(() => {
|
||||||
|
const start = Number(formModel.start || 0);
|
||||||
|
const digits = effectiveDigits.value;
|
||||||
|
const quantity = Number(formModel.quantity || 1);
|
||||||
|
|
||||||
|
return Array.from({ length: quantity }, (_, index) => {
|
||||||
|
const codeNumber = String(start + index).padStart(digits, "0");
|
||||||
|
return `${formModel.prefix}${codeNumber}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const startMinDigits = computed(() => Math.max(minDigits, Math.min(maxDigits, normalizeNumber(formModel.start).length || minDigits)));
|
||||||
|
const effectiveDigits = computed(() => Math.max(startMinDigits.value, Math.min(maxDigits, formModel.digits || defaultDigits)));
|
||||||
|
const maxCodeLength = computed(() => formModel.prefix.length + effectiveDigits.value);
|
||||||
|
const previewPages = computed(() => splitManagementQrCodePages(codeList.value, maxCodeLength.value));
|
||||||
|
const visiblePreviewPages = computed(() => previewPages.value.slice(0, 1));
|
||||||
|
const paperGrid = computed(() => calculateManagementQrCodeGrid(maxCodeLength.value));
|
||||||
|
const paperStyle = computed(() => ({
|
||||||
|
gridTemplateRows: `repeat(${paperGrid.value.rows}, minmax(0, 1fr))`,
|
||||||
|
gridTemplateColumns: `repeat(${paperGrid.value.columns}, minmax(0, 1fr))`
|
||||||
|
}));
|
||||||
|
const paperCount = computed(() => previewPages.value.length);
|
||||||
|
const finalStartCode = computed(() => codeList.value[0] || "");
|
||||||
|
const finalEndCode = computed(() => codeList.value[codeList.value.length - 1] || "");
|
||||||
|
const downloadText = computed(() => `下载图片(${paperCount.value} 张)`);
|
||||||
|
const previewSummary = computed(() => `共 ${paperCount.value} 张 A4,每张最多 ${paperGrid.value.pageSize} 枚,${paperGrid.value.columns} × ${paperGrid.value.rows} 网格`);
|
||||||
|
|
||||||
|
function normalizeNumber(value: string | number | undefined): string {
|
||||||
|
return String(value ?? "").replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDigits(value: string | number | undefined, minValue = minDigits): number {
|
||||||
|
const nextValue = Number(normalizeNumber(value));
|
||||||
|
const validValue = Number.isFinite(nextValue) && 0 < nextValue ? nextValue : defaultDigits;
|
||||||
|
return Math.max(minValue, Math.min(maxDigits, validValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocalConfig(): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = window.localStorage.getItem(storageKey);
|
||||||
|
if (!cache) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(cache) as Partial<ManagementQrCodeFormModel>;
|
||||||
|
const start = normalizeNumber(config.start);
|
||||||
|
const startDigits = Math.max(minDigits, Math.min(maxDigits, start.length || minDigits));
|
||||||
|
|
||||||
|
formModel.prefix = String(config.prefix ?? formModel.prefix);
|
||||||
|
formModel.start = start;
|
||||||
|
formModel.digits = normalizeDigits(config.digits, startDigits);
|
||||||
|
formModel.quantity = normalizeNumber(config.quantity) || formModel.quantity;
|
||||||
|
} catch {
|
||||||
|
window.localStorage.removeItem(storageKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLocalConfig(): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify({
|
||||||
|
prefix: formModel.prefix,
|
||||||
|
start: formModel.start,
|
||||||
|
digits: formModel.digits,
|
||||||
|
quantity: formModel.quantity
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNumberChange(key: "start" | "quantity", value: string | number | undefined): void {
|
||||||
|
const nextValue = normalizeNumber(value);
|
||||||
|
formModel[key] = nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLocalConfig();
|
||||||
|
|
||||||
|
watch(startMinDigits, value => {
|
||||||
|
if (formModel.digits < value) {
|
||||||
|
formModel.digits = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(formModel, () => {
|
||||||
|
saveLocalConfig();
|
||||||
|
}, {
|
||||||
|
deep: true
|
||||||
|
});
|
||||||
|
|
||||||
|
async function downloadImages(): Promise<void> {
|
||||||
|
if (downloading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadManagementQrCodeImages({
|
||||||
|
codes: codeList.value,
|
||||||
|
filePrefix: `${finalStartCode.value}-${finalEndCode.value}`,
|
||||||
|
maxCodeLength: maxCodeLength.value
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
downloading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printQrCode(): Promise<void> {
|
||||||
|
if (printing.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printing.value = true;
|
||||||
|
try {
|
||||||
|
await printManagementQrCodeImages({
|
||||||
|
codes: codeList.value,
|
||||||
|
maxCodeLength: maxCodeLength.value,
|
||||||
|
title: `${finalStartCode.value}-${finalEndCode.value}`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
Toast({
|
||||||
|
theme: "warning",
|
||||||
|
direction: "row",
|
||||||
|
message: err instanceof Error ? err.message : "打印失败"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
printing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.qr-page {
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: .5rem 0;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
|
||||||
|
:deep(.t-input__control) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
padding: 0 1rem;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin: 0 0 .75rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #0f172a;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
color: #52607a;
|
||||||
|
margin: 0;
|
||||||
|
font-size: .8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper {
|
||||||
|
gap: 0;
|
||||||
|
width: min(100%, 28rem);
|
||||||
|
padding: .75rem;
|
||||||
|
display: grid;
|
||||||
|
margin: 0 auto;
|
||||||
|
aspect-ratio: 210 / 297;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 .25rem 1rem rgba(15, 23, 42, .2);
|
||||||
|
|
||||||
|
.item {
|
||||||
|
gap: .1875rem;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
padding: .25rem;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.qr-code {
|
||||||
|
width: 58%;
|
||||||
|
background: repeating-linear-gradient(45deg, #000 0 .125rem, #000 .125rem .25rem, #fff .25rem .375rem, #fff .375rem .5rem);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-mark {
|
||||||
|
width: 50%;
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
gap: .75rem;
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
display: grid;
|
||||||
|
position: fixed;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.qr-page {
|
||||||
|
.form {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-range {
|
||||||
|
div {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-head {
|
||||||
|
h2 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper {
|
||||||
|
background: #f8fafc;
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-page">
|
||||||
|
<t-cell-group title="显示" theme="card">
|
||||||
|
<t-cell class="theme" title="显示模式">
|
||||||
|
<template #right-icon>
|
||||||
|
<segmented-slider
|
||||||
|
class="theme-slider"
|
||||||
|
:model-value="globalUIStore.themeMode"
|
||||||
|
:options="themeOptions"
|
||||||
|
@change="handleThemeChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group title="声音" theme="card">
|
||||||
|
<t-cell title="扫码成功">
|
||||||
|
<template #right-icon>
|
||||||
|
<t-switch :model-value="settingStore.sound.scanSuccess" @change="value => handleScanSuccessChange(value === true)" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<t-cell title="登记成功">
|
||||||
|
<template #right-icon>
|
||||||
|
<t-switch :model-value="settingStore.sound.registerSuccess" @change="value => handleRegisterSuccessChange(value === true)" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<t-cell title="音量">
|
||||||
|
<template #right-icon>
|
||||||
|
<div class="volume-field">
|
||||||
|
<t-slider
|
||||||
|
:model-value="settingStore.sound.volume"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:step="1"
|
||||||
|
@change="handleVolumeChange"
|
||||||
|
/>
|
||||||
|
<span v-text="`${settingStore.sound.volume}%`" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ThemeMode } from "@/store/globalUI";
|
||||||
|
import { useGlobalUIStore } from "@/store/globalUI";
|
||||||
|
import SegmentedSlider from "@/components/SegmentedSlider.vue";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ManagementSettings"
|
||||||
|
});
|
||||||
|
|
||||||
|
const globalUIStore = useGlobalUIStore();
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const themeOptions: Array<{ label: string; value: ThemeMode }> = [
|
||||||
|
{ label: "浅色", value: "light" },
|
||||||
|
{ label: "深色", value: "dark" },
|
||||||
|
{ label: "跟随系统", value: "system" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleThemeChange(value: string): void {
|
||||||
|
globalUIStore.setThemeMode(value as ThemeMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScanSuccessChange(value: boolean): Promise<void> {
|
||||||
|
settingStore.setSound({
|
||||||
|
scanSuccess: value
|
||||||
|
});
|
||||||
|
if (value) {
|
||||||
|
await playSettingSoundPreview("scanSuccess");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegisterSuccessChange(value: boolean): Promise<void> {
|
||||||
|
settingStore.setSound({
|
||||||
|
registerSuccess: value
|
||||||
|
});
|
||||||
|
if (value) {
|
||||||
|
await playSettingSoundPreview("registerSuccess");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVolumeChange(value: number | number[]): void {
|
||||||
|
settingStore.setSound({
|
||||||
|
volume: Array.isArray(value) ? value[0] : value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playSettingSoundPreview(type: "scanSuccess" | "registerSuccess"): Promise<void> {
|
||||||
|
await settingStore.unlockSoundPlayers();
|
||||||
|
if (type === "scanSuccess") {
|
||||||
|
await settingStore.playScanSuccessSound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await settingStore.playRegisterSuccessSound();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.settings-page {
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.theme {
|
||||||
|
|
||||||
|
:deep(.t-cell__title) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-slider {
|
||||||
|
width: 12rem;
|
||||||
|
flex: 0 0 12rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-field {
|
||||||
|
gap: .5rem;
|
||||||
|
width: 16rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
:deep(.t-slider) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .8125rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.settings-page {
|
||||||
|
.volume-field span {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div v-role="permissionManagementRoleAccess" v-can="pageAccess" class="permission-edit">
|
||||||
|
<t-form class="form" label-align="left">
|
||||||
|
<t-form-item label="名称">
|
||||||
|
<t-input v-model.trim="formModel.name" placeholder="权限名称" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="代码">
|
||||||
|
<t-input v-model.trim="formModel.code" placeholder="如 customer:read" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="模块">
|
||||||
|
<t-input v-model.trim="formModel.moduleCode" placeholder="模块代码" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="说明">
|
||||||
|
<t-textarea v-model.trim="formModel.description" class="textarea" placeholder="权限说明" autosize borderless />
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
<t-button v-can="savePermissionCode" class="save" theme="primary" @click="savePermission">保存</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { gaoModuleCode, usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||||
|
import { corePermissionCodes, permissionManagementRoleAccess } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "PermissionEdit"
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const permissionStore = usePermissionSettingsStore();
|
||||||
|
const routeId = computed(() => String(route.params.id || ""));
|
||||||
|
const isCreateMode = computed(() => !routeId.value);
|
||||||
|
const savePermissionCode = computed(() => isCreateMode.value ? corePermissionCodes.permission.create : corePermissionCodes.permission.update);
|
||||||
|
const pageAccess = computed(() => ({
|
||||||
|
value: [corePermissionCodes.role.read, corePermissionCodes.permission.read, savePermissionCode.value],
|
||||||
|
mode: "and" as const
|
||||||
|
}));
|
||||||
|
const formModel = reactive({
|
||||||
|
id: "",
|
||||||
|
moduleCode: gaoModuleCode,
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
description: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fillForm(): Promise<void> {
|
||||||
|
await permissionStore.loadAll();
|
||||||
|
const current = await permissionStore.loadPermissionDetail(routeId.value);
|
||||||
|
Object.assign(formModel, {
|
||||||
|
id: current.id,
|
||||||
|
moduleCode: current.moduleCode || gaoModuleCode,
|
||||||
|
code: current.code,
|
||||||
|
name: current.name,
|
||||||
|
description: current.description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePermission(): Promise<void> {
|
||||||
|
const permission = await permissionStore.upsertPermission({
|
||||||
|
id: formModel.id,
|
||||||
|
moduleCode: gaoModuleCode,
|
||||||
|
code: formModel.code,
|
||||||
|
name: formModel.name,
|
||||||
|
description: formModel.description
|
||||||
|
}, isCreateMode.value);
|
||||||
|
Toast({
|
||||||
|
theme: "success",
|
||||||
|
direction: "row",
|
||||||
|
message: "权限已保存"
|
||||||
|
});
|
||||||
|
await router.push(isCreateMode.value ? "/management/permissions/items" : `/management/permissions/items/${permission?.id || formModel.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fillForm();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.permission-edit {
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 5rem 0;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
border-radius: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div v-role="permissionManagementRoleAccess" v-can="{ value: [corePermissionCodes.role.read, corePermissionCodes.permission.read], mode: 'and' }" class="permission-list">
|
||||||
|
<t-cell-group v-if="sortedPermissions.length" theme="card">
|
||||||
|
<t-swipe-cell
|
||||||
|
v-for="item in sortedPermissions"
|
||||||
|
:key="item.id"
|
||||||
|
class="item"
|
||||||
|
>
|
||||||
|
<t-cell
|
||||||
|
:title="item.name"
|
||||||
|
>
|
||||||
|
<template #note>
|
||||||
|
<span class="code" v-text="item.code"></span>
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<template #right>
|
||||||
|
<t-button v-can="corePermissionCodes.permission.delete" class="delete" theme="danger" @click="removePermission(item.id)">删除</t-button>
|
||||||
|
</template>
|
||||||
|
</t-swipe-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<t-empty v-else-if="!permissionStore.loading" description="暂无权限" />
|
||||||
|
<t-button v-can="corePermissionCodes.permission.create" class="add" theme="primary" type="button" @click="goCreate">
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="add" />
|
||||||
|
</template>
|
||||||
|
新增权限
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Permission } from "@/store/permissionSettingsStore";
|
||||||
|
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||||
|
import { corePermissionCodes, permissionManagementRoleAccess } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "PermissionList"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const permissionStore = usePermissionSettingsStore();
|
||||||
|
const sortedPermissions = computed<Permission[]>(() => [...permissionStore.permissionList].sort((left, right) => left.code.localeCompare(right.code)));
|
||||||
|
|
||||||
|
async function goCreate(): Promise<void> {
|
||||||
|
await router.push("/management/permissions/items/create");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePermission(id: string): Promise<void> {
|
||||||
|
await permissionStore.removePermission(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await permissionStore.loadAll();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.permission-list {
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
:deep(.t-cell__title) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-cell__title-text) {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-cell__note) {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 56%;
|
||||||
|
text-align: right;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-cell__right) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
|
||||||
|
:deep(.t-cell:after) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-can="{ value: [corePermissionCodes.role.read, corePermissionCodes.permission.read], mode: 'and' }"
|
||||||
|
class="permission-settings"
|
||||||
|
>
|
||||||
|
<t-cell-group title="权限配置" theme="card">
|
||||||
|
<t-cell v-can="corePermissionCodes.role.read" title="角色管理" :note="`${permissionStore.roleList.length} 个角色`" arrow hover @click="go('/management/permissions/roles')">
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-icon name="user-setting" size="1.75rem" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<t-cell v-can="corePermissionCodes.permission.read" title="权限管理" :note="`${permissionStore.permissionList.length} 个权限`" arrow hover @click="go('/management/permissions/items')">
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-icon name="secured" size="1.75rem" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<t-cell v-can="rolePermissionAssignAccess" title="角色权限授权" note="为角色分配可用权限" arrow hover @click="go('/management/permissions/assign')">
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-icon name="user-checked" size="1.75rem" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<t-cell v-can="corePermissionCodes.userRole.read" title="用户角色授权" note="为用户分配业务角色" arrow hover @click="go('/management/permissions/user-roles')">
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-icon name="usergroup" size="1.75rem" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<div class="struct">
|
||||||
|
<div class="header">
|
||||||
|
<span class="title">当前角色结构</span>
|
||||||
|
<span class="tips orange">上级角色包含子级角色所有权限</span>
|
||||||
|
</div>
|
||||||
|
<t-cell-group theme="card">
|
||||||
|
<t-cell
|
||||||
|
v-can="{ value: [corePermissionCodes.role.read, corePermissionCodes.permission.read], mode: 'and' }"
|
||||||
|
v-for="item in permissionStore.roleTree"
|
||||||
|
:key="item.id"
|
||||||
|
:note="`${item.permissionIdList.length} 项权限`"
|
||||||
|
arrow
|
||||||
|
hover
|
||||||
|
@click="goAssign(item.id)"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="title" v-text="item.name" :style="{ paddingLeft: `${item.level * 1.25}rem` }" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||||
|
import { corePermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "PermissionSettings"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const permissionStore = usePermissionSettingsStore();
|
||||||
|
const rolePermissionAssignAccess = {
|
||||||
|
value: [
|
||||||
|
corePermissionCodes.role.read,
|
||||||
|
corePermissionCodes.permission.read,
|
||||||
|
corePermissionCodes.rolePermission.read,
|
||||||
|
corePermissionCodes.roleRelation.read
|
||||||
|
],
|
||||||
|
mode: "and"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function go(path: string): Promise<void> {
|
||||||
|
await router.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goAssign(roleId: string): Promise<void> {
|
||||||
|
await router.push({
|
||||||
|
path: "/management/permissions/assign",
|
||||||
|
query: {
|
||||||
|
roleId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await permissionStore.loadAll();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.permission-settings {
|
||||||
|
gap: 1rem;
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.struct {
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 var(--td-cell-group-title-padding-left, 16px);
|
||||||
|
font-size: var(--td-cell-group-title-font-size, 14px);
|
||||||
|
text-align: left;
|
||||||
|
background: var(--td-cell-group-title-bg-color, var(--td-bg-color-secondarycontainer, var(--td-gray-color-1, #f3f3f3)));
|
||||||
|
line-height: var(--td-cell-group-title-line-height, 45px);
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: var(--td-cell-group-title-color, var(--td-text-color-placeholder, var(--td-font-gray-3, rgba(0, 0, 0, 0.4))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="pageAccess" class="registration-event-edit">
|
||||||
|
<t-form class="form" label-align="left">
|
||||||
|
<t-form-item label="名称">
|
||||||
|
<t-input v-model.trim="formModel.name" placeholder="登记事件名称" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="说明">
|
||||||
|
<t-textarea v-model.trim="formModel.description" class="textarea" placeholder="事件说明" autosize borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="颜色">
|
||||||
|
<label class="color-picker">
|
||||||
|
<input v-model="formModel.color" type="color">
|
||||||
|
<span v-text="formModel.color" />
|
||||||
|
</label>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="状态">
|
||||||
|
<t-radio-group v-model="formModel.status" class="inline-options" variant="default-filled">
|
||||||
|
<t-radio :block="false" borderless value="ACTIVE">有效</t-radio>
|
||||||
|
<t-radio :block="false" borderless value="INACTIVE">无效</t-radio>
|
||||||
|
</t-radio-group>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="启用">
|
||||||
|
<t-switch v-model="formModel.enabled" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="拍照">
|
||||||
|
<t-switch v-model="formModel.requirePhoto" />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="排序">
|
||||||
|
<t-input v-model.trim="formModel.sortText" type="number" placeholder="排序值" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="约束">
|
||||||
|
<t-radio-group v-model="formModel.limitType" class="limit-options" variant="default-filled">
|
||||||
|
<t-radio
|
||||||
|
v-for="option in limitOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:block="false"
|
||||||
|
borderless
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
<span v-text="option.label" />
|
||||||
|
</t-radio>
|
||||||
|
</t-radio-group>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item v-if="isValueLimitType(formModel.limitType)" label="数值">
|
||||||
|
<t-input v-model.trim="formModel.limitValueText" type="number" :placeholder="limitValuePlaceholder" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
<section class="roles">
|
||||||
|
<div class="title">
|
||||||
|
<span>可登记角色</span>
|
||||||
|
<span class="hint">未选择角色时不限制角色</span>
|
||||||
|
</div>
|
||||||
|
<t-checkbox-group v-model="formModel.roleCodeList" class="group">
|
||||||
|
<t-checkbox
|
||||||
|
v-for="item in roleOptions"
|
||||||
|
:key="item.code"
|
||||||
|
:value="item.code"
|
||||||
|
class="item"
|
||||||
|
borderless
|
||||||
|
>
|
||||||
|
<span v-text="item.name || item.code" />
|
||||||
|
</t-checkbox>
|
||||||
|
</t-checkbox-group>
|
||||||
|
</section>
|
||||||
|
<t-button v-can="savePermissionCode" class="save" theme="primary" @click="saveRegistrationEvent">保存</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import type { Role } from "timi-web";
|
||||||
|
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||||
|
import GaoUserAPI from "@/api/GaoUserAPI";
|
||||||
|
import type { RegistrationEvent, RegistrationEventStatus, RegistrationLimitType } from "@/types/RegistrationEvent";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
type LimitOption = {
|
||||||
|
label: string;
|
||||||
|
value: RegistrationLimitType;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "RegistrationEventEdit"
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const routeId = computed(() => String(route.params.id || ""));
|
||||||
|
const isCreateMode = computed(() => !routeId.value);
|
||||||
|
const savePermissionCode = computed(() => isCreateMode.value ? gaoPermissionCodes.registrationEvent.create : gaoPermissionCodes.registrationEvent.update);
|
||||||
|
const pageAccess = computed(() => ({
|
||||||
|
value: [gaoPermissionCodes.registrationEvent.read, gaoPermissionCodes.user.read, savePermissionCode.value],
|
||||||
|
mode: "and" as const
|
||||||
|
}));
|
||||||
|
const roleOptions = ref<Role[]>([]);
|
||||||
|
const limitOptions: LimitOption[] = [
|
||||||
|
{ label: "不限", value: "NONE" },
|
||||||
|
{ label: "上午/下午各一次", value: "DAILY_HALF_DAY" },
|
||||||
|
{ label: "每天一次", value: "NATURAL_DAY" },
|
||||||
|
{ label: "间隔小时", value: "INTERVAL_HOUR" },
|
||||||
|
{ label: "每月一次", value: "NATURAL_MONTH" },
|
||||||
|
{ label: "累计次数", value: "TOTAL_LIMIT" }
|
||||||
|
];
|
||||||
|
const formModel = reactive({
|
||||||
|
id: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
color: "#2f88ff",
|
||||||
|
status: "ACTIVE" as RegistrationEventStatus,
|
||||||
|
enabled: true,
|
||||||
|
requirePhoto: false,
|
||||||
|
limitType: "NONE" as RegistrationLimitType,
|
||||||
|
limitValueText: "",
|
||||||
|
sortText: "0",
|
||||||
|
roleCodeList: [] as string[]
|
||||||
|
});
|
||||||
|
const limitValuePlaceholder = computed(() => formModel.limitType === "INTERVAL_HOUR" ? "请输入间隔小时数" : "请输入累计上限次数");
|
||||||
|
|
||||||
|
function isValueLimitType(value: RegistrationLimitType): boolean {
|
||||||
|
return value === "INTERVAL_HOUR" || value === "TOTAL_LIMIT";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLimitValue(): number | undefined {
|
||||||
|
if (!isValueLimitType(formModel.limitType)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Number(formModel.limitValueText);
|
||||||
|
return 0 < value ? Math.floor(value) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(): boolean {
|
||||||
|
if (!formModel.name.trim()) {
|
||||||
|
showToast("请输入登记事件名称", "warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isValueLimitType(formModel.limitType) && !resolveLimitValue()) {
|
||||||
|
showToast("请输入有效的约束数值", "warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(): RegistrationEvent {
|
||||||
|
return {
|
||||||
|
id: formModel.id || undefined,
|
||||||
|
name: formModel.name.trim(),
|
||||||
|
description: formModel.description,
|
||||||
|
color: formModel.color,
|
||||||
|
status: formModel.status,
|
||||||
|
enabled: formModel.enabled,
|
||||||
|
requirePhoto: formModel.requirePhoto,
|
||||||
|
limitType: formModel.limitType,
|
||||||
|
limitValue: resolveLimitValue(),
|
||||||
|
sort: Math.floor(Number(formModel.sortText) || 0),
|
||||||
|
roleCodeList: [...formModel.roleCodeList]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRegistrationEvent(): Promise<void> {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = buildPayload();
|
||||||
|
if (isCreateMode.value) {
|
||||||
|
await RegistrationEventAPI.create(payload);
|
||||||
|
} else {
|
||||||
|
await RegistrationEventAPI.update(payload);
|
||||||
|
}
|
||||||
|
showToast("登记事件已保存");
|
||||||
|
await router.replace("/management/registration-events");
|
||||||
|
} catch {
|
||||||
|
showToast("登记事件保存失败", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, theme: "success" | "error" | "warning" = "success"): void {
|
||||||
|
Toast({
|
||||||
|
theme,
|
||||||
|
direction: "row",
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillForm(): Promise<void> {
|
||||||
|
roleOptions.value = await GaoUserAPI.roleList();
|
||||||
|
if (isCreateMode.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await RegistrationEventAPI.detail(routeId.value);
|
||||||
|
Object.assign(formModel, {
|
||||||
|
id: current.id || "",
|
||||||
|
name: current.name || "",
|
||||||
|
description: current.description || "",
|
||||||
|
color: current.color || "#2f88ff",
|
||||||
|
status: current.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
|
||||||
|
enabled: current.enabled ?? true,
|
||||||
|
requirePhoto: current.requirePhoto ?? false,
|
||||||
|
limitType: current.limitType || "NONE",
|
||||||
|
limitValueText: current.limitValue ? String(current.limitValue) : "",
|
||||||
|
sortText: String(current.sort ?? 0),
|
||||||
|
roleCodeList: [...(current.roleCodeList || [])]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await fillForm();
|
||||||
|
} catch {
|
||||||
|
showToast("登记事件加载失败", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.registration-event-edit {
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 5rem 0;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
border-radius: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-options,
|
||||||
|
.limit-options {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
gap: .25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
span:first-child {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
gap: .5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
||||||
|
.item {
|
||||||
|
margin: 0;
|
||||||
|
padding: .875rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(148, 163, 184, .22);
|
||||||
|
border-radius: .75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.registration-event-edit {
|
||||||
|
.color-picker span,
|
||||||
|
.roles .title .hint {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles .group .item {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, .72);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="{ value: [gaoPermissionCodes.registrationEvent.read, gaoPermissionCodes.user.read], mode: 'and' }" class="registration-event-list">
|
||||||
|
<article
|
||||||
|
class="item"
|
||||||
|
v-for="item in registrationEventList"
|
||||||
|
:key="item.id"
|
||||||
|
>
|
||||||
|
<div class="main">
|
||||||
|
<span class="color" :style="{ background: item.color || defaultColor }" />
|
||||||
|
<div class="content">
|
||||||
|
<div class="title">
|
||||||
|
<strong v-text="item.name || '未命名事件'" />
|
||||||
|
<t-tag
|
||||||
|
size="small"
|
||||||
|
theme="primary"
|
||||||
|
:variant="isRegistrationEventActive(item) ? 'light' : 'outline'"
|
||||||
|
:content="getStatusText(item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="item.description" v-text="item.description" />
|
||||||
|
<div class="meta">
|
||||||
|
<span v-text="getLimitText(item)" />
|
||||||
|
<span v-if="item.requirePhoto">需拍照</span>
|
||||||
|
<span v-text="`${item.roleCodeList?.length || 0} 个角色`" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="roles">
|
||||||
|
<t-tag
|
||||||
|
v-for="roleCode in item.roleCodeList"
|
||||||
|
:key="roleCode"
|
||||||
|
theme="primary"
|
||||||
|
variant="light-outline"
|
||||||
|
:content="getRoleName(roleCode)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<t-button
|
||||||
|
v-can="gaoPermissionCodes.registrationEvent.update"
|
||||||
|
theme="primary"
|
||||||
|
variant="outline"
|
||||||
|
size="small"
|
||||||
|
@click="editRegistrationEvent(item)"
|
||||||
|
content="编辑"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="edit-1" size="small" />
|
||||||
|
</template>
|
||||||
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
v-can="gaoPermissionCodes.registrationEvent.delete"
|
||||||
|
theme="danger"
|
||||||
|
variant="outline"
|
||||||
|
size="small"
|
||||||
|
@click="removeRegistrationEvent(item)"
|
||||||
|
content="删除"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="delete" size="small" />
|
||||||
|
</template>
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<div v-if="!registrationEventList.length" class="empty-block">暂无登记事件</div>
|
||||||
|
<t-button v-can="gaoPermissionCodes.registrationEvent.create" class="create" theme="primary" @click="createRegistrationEvent">
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="add" />
|
||||||
|
</template>
|
||||||
|
新增登记事件
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import type { Role } from "timi-web";
|
||||||
|
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||||
|
import GaoUserAPI from "@/api/GaoUserAPI";
|
||||||
|
import type { RegistrationEvent, RegistrationLimitType } from "@/types/RegistrationEvent";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "RegistrationEventList"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const defaultColor = "#2f88ff";
|
||||||
|
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||||
|
const roleOptions = ref<Role[]>([]);
|
||||||
|
|
||||||
|
function createRegistrationEvent(): void {
|
||||||
|
router.push("/management/registration-events/create");
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRegistrationEvent(item: RegistrationEvent): void {
|
||||||
|
if (!item.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/management/registration-events/${item.id}/edit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRegistrationEvent(item: RegistrationEvent): Promise<void> {
|
||||||
|
if (!item.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await RegistrationEventAPI.remove(item.id);
|
||||||
|
Toast({
|
||||||
|
theme: "success",
|
||||||
|
direction: "row",
|
||||||
|
message: "登记事件已删除"
|
||||||
|
});
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
direction: "row",
|
||||||
|
message: "登记事件删除失败"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRegistrationEventActive(item: RegistrationEvent): boolean {
|
||||||
|
return item.enabled !== false && (item.status || "ACTIVE") === "ACTIVE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(item: RegistrationEvent): string {
|
||||||
|
if (item.status === "INACTIVE") {
|
||||||
|
return "无效";
|
||||||
|
}
|
||||||
|
if (item.status === "DELETED") {
|
||||||
|
return "已删除";
|
||||||
|
}
|
||||||
|
return item.enabled === false ? "停用" : "有效";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLimitText(item: RegistrationEvent): string {
|
||||||
|
const limitTextMap: Record<RegistrationLimitType, string> = {
|
||||||
|
NONE: "不限次数",
|
||||||
|
DAILY_HALF_DAY: "上午/下午各一次",
|
||||||
|
NATURAL_DAY: "每天一次",
|
||||||
|
INTERVAL_HOUR: `每隔 ${item.limitValue || 0} 小时`,
|
||||||
|
NATURAL_MONTH: "每月一次",
|
||||||
|
TOTAL_LIMIT: `累计限 ${item.limitValue || 0} 次`
|
||||||
|
};
|
||||||
|
return limitTextMap[item.limitType || "NONE"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleName(code: string): string {
|
||||||
|
const role = roleOptions.value.find(item => item.code === code);
|
||||||
|
return role?.name || code;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData(): Promise<void> {
|
||||||
|
const [eventResult, roles] = await Promise.all([
|
||||||
|
RegistrationEventAPI.list({
|
||||||
|
index: 0,
|
||||||
|
size: 100
|
||||||
|
}),
|
||||||
|
GaoUserAPI.roleList()
|
||||||
|
]);
|
||||||
|
roleOptions.value = roles || [];
|
||||||
|
registrationEventList.value = eventResult.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
direction: "row",
|
||||||
|
message: "登记事件加载失败"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.registration-event-list {
|
||||||
|
gap: .875rem;
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
gap: .75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
display: grid;
|
||||||
|
border: 1px solid rgba(148, 163, 184, .16);
|
||||||
|
background: rgba(255, 255, 255, .96);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: .75rem;
|
||||||
|
|
||||||
|
.main {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.color {
|
||||||
|
width: .875rem;
|
||||||
|
height: .875rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: .25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: .375rem 0 0 0;
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
margin-top: .5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles {
|
||||||
|
gap: .375rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
color: #52607a;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.registration-event-list {
|
||||||
|
.item {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .28);
|
||||||
|
|
||||||
|
.content {
|
||||||
|
p,
|
||||||
|
.meta span {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="gaoPermissionCodes.registrationEventRecord.read" class="registration-record-detail">
|
||||||
|
<customer-profile-card v-if="record?.customer" class="customer-profile" :customer="record.customer" />
|
||||||
|
<t-cell-group v-if="record" title="登记信息" theme="card">
|
||||||
|
<t-cell title="登记事件" :note="record.registrationEvent?.name || '未知事件'" />
|
||||||
|
<t-cell title="登记时间" :note="formatTime(record.registeredAt)" />
|
||||||
|
<t-cell title="登记人" :note="getOperatorName(record)" />
|
||||||
|
<t-cell title="客户编码" :note="record.customer?.code || record.customerId || '未知'" />
|
||||||
|
<t-cell title="备注" :note="record.remark || '无'" />
|
||||||
|
</t-cell-group>
|
||||||
|
<section v-if="record" class="photo-panel">
|
||||||
|
<photo-scroller
|
||||||
|
:model-value="recordPhotoList"
|
||||||
|
:show-add="false"
|
||||||
|
:show-remove="false"
|
||||||
|
title="登记照片"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<t-empty v-else :description="loading ? '正在加载登记事件记录' : '登记事件记录不存在'" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Time, useUserStore } from "timi-web";
|
||||||
|
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
|
||||||
|
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||||
|
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||||
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
|
import type { RegistrationEventRecord } from "@/types/RegistrationEventRecord";
|
||||||
|
import { getRegistrationEventRecordPhotoURL } from "@/utils/registrationEventRecord";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "RegistrationEventRecordDetail"
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const navBarStore = useNavBarStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const record = ref<RegistrationEventRecord>();
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const canUpdateRecord = computed(() => userStore.hasPermissions(gaoPermissionCodes.registrationEventRecord.update));
|
||||||
|
const recordPhotoList = computed(() => {
|
||||||
|
const url = getRegistrationEventRecordPhotoURL(record.value);
|
||||||
|
return url ? [url] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadRecord(): Promise<void> {
|
||||||
|
const recordId = String(route.params.id || "");
|
||||||
|
if (!recordId) {
|
||||||
|
record.value = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
record.value = await RegistrationEventRecordAPI.detail(recordId);
|
||||||
|
} catch (err) {
|
||||||
|
record.value = undefined;
|
||||||
|
console.warn("登记事件记录加载失败", err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goEdit(): Promise<void> {
|
||||||
|
const recordId = String(route.params.id || "");
|
||||||
|
if (!recordId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await router.push(`/management/registration-event-records/${recordId}/edit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: number): string {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = Time.toDateObject(value);
|
||||||
|
if (!date) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return Time.formatDateTime(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOperatorName(item: RegistrationEventRecord): string {
|
||||||
|
return item.operator?.nick || item.operator?.name || "未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => route.params.id, async () => {
|
||||||
|
await loadRecord();
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
navBarStore.clearRight();
|
||||||
|
if (canUpdateRecord.value) {
|
||||||
|
navBarStore.setRightText("编辑", "var(--td-brand-color)", goEdit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
navBarStore.clearRight();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.registration-record-detail {
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.customer-profile {
|
||||||
|
margin: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-panel {
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 0 1rem;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.registration-record-detail {
|
||||||
|
.photo-panel {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="pageAccess" class="registration-record-edit">
|
||||||
|
<t-form class="form" label-align="left">
|
||||||
|
<t-form-item label="客户" required-mark>
|
||||||
|
<t-input
|
||||||
|
:model-value="customerDisplayText"
|
||||||
|
class="customer"
|
||||||
|
readonly
|
||||||
|
borderless
|
||||||
|
placeholder="选择或扫码客户"
|
||||||
|
>
|
||||||
|
<template #suffix-icon>
|
||||||
|
<div class="actions">
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
variant="outline"
|
||||||
|
size="extra-small"
|
||||||
|
@click="selectorVisible = true"
|
||||||
|
>选择</t-button>
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
size="extra-small"
|
||||||
|
@click="toggleScanner"
|
||||||
|
>
|
||||||
|
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</t-input>
|
||||||
|
</t-form-item>
|
||||||
|
<div v-if="scannerVisible" class="code-scanner-wrapper">
|
||||||
|
<code-scanner
|
||||||
|
visible
|
||||||
|
:status-text="scanStatus"
|
||||||
|
active-text="正在识别客户编码,请将条码或二维码移入取景框。"
|
||||||
|
@decode="handleDecodedText"
|
||||||
|
@status-change="handleScanStatusChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<customer-profile-card
|
||||||
|
v-if="selectedCustomer"
|
||||||
|
class="customer-profile"
|
||||||
|
:customer="selectedCustomer"
|
||||||
|
/>
|
||||||
|
<registration-event-selector
|
||||||
|
v-model="selectedRegistrationEventIdList"
|
||||||
|
class="registration-event-selector"
|
||||||
|
:registration-event-list="registrationEventList"
|
||||||
|
required-mark
|
||||||
|
/>
|
||||||
|
<t-form-item label="登记时间" content-align="right">
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
variant="outline"
|
||||||
|
size="extra-small"
|
||||||
|
:content="registeredAtText || '此刻'"
|
||||||
|
@click="openRegisteredAtPicker"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="登记备注">
|
||||||
|
<t-textarea
|
||||||
|
v-model.trim="note"
|
||||||
|
class="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||||
|
placeholder="登记说明"
|
||||||
|
/>
|
||||||
|
</t-form-item>
|
||||||
|
|
||||||
|
<div class="photo-part">
|
||||||
|
<photo-scroller v-model="photoList" @add="capturePhoto" />
|
||||||
|
</div>
|
||||||
|
</t-form>
|
||||||
|
<t-cell-group
|
||||||
|
v-if="recentRecordList.length"
|
||||||
|
title="最近登记事件列表"
|
||||||
|
class="record-list"
|
||||||
|
theme="card"
|
||||||
|
>
|
||||||
|
<t-cell
|
||||||
|
v-for="item in recentRecordList"
|
||||||
|
:key="item.id"
|
||||||
|
:title="getRecordRegistrationEventName(item)"
|
||||||
|
:description="formatTime(item.registeredAt)"
|
||||||
|
arrow
|
||||||
|
hover
|
||||||
|
@click="goRecordDetail(item.id || '')"
|
||||||
|
>
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-image v-if="getRecordPhotoURL(item)" :src="getRecordPhotoURL(item)" alt="事件照片" fit="cover" class="record-photo" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<div v-else class="empty">当前客户暂无事件记录</div>
|
||||||
|
<customer-selector
|
||||||
|
v-model="selectedCustomerId"
|
||||||
|
v-model:visible="selectorVisible"
|
||||||
|
@change="handleCustomerChange"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="recordPhotoInputRef"
|
||||||
|
class="file-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
@change="handleRecordPhotoSelected"
|
||||||
|
>
|
||||||
|
<t-button theme="primary" class="save" :disabled="!canSubmit" :loading="saving" @click="submitRecord">
|
||||||
|
<span v-text="saveButtonText" />
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<t-popup v-model="registeredAtPickerVisible" placement="bottom">
|
||||||
|
<t-date-time-picker
|
||||||
|
v-model="registeredAtDraftText"
|
||||||
|
:default-value="registeredAtDraftText"
|
||||||
|
:mode="['date', 'minute']"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
title="登记时间"
|
||||||
|
@cancel="registeredAtPickerVisible = false"
|
||||||
|
@confirm="confirmRegisteredAt"
|
||||||
|
/>
|
||||||
|
</t-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { AttachmentAPI, Time, type Attachment } from "timi-web";
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import CustomerAPI from "@/api/CustomerAPI";
|
||||||
|
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||||
|
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
|
||||||
|
import CodeScanner from "@/components/CodeScanner.vue";
|
||||||
|
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||||
|
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||||
|
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||||
|
import RegistrationEventSelector from "@/components/RegistrationEventSelector.vue";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import type { Customer } from "@/types/Customer";
|
||||||
|
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||||
|
import type { RegistrationEventRecord } from "@/types/RegistrationEventRecord";
|
||||||
|
import { getRegistrationEventRecordPhotoURL } from "@/utils/registrationEventRecord";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
import { compressUploadImageList } from "@/utils/imageCompression";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "RegistrationEventRecordEdit"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const customerCode = ref("");
|
||||||
|
const scanCode = ref(customerCode.value);
|
||||||
|
const selectorVisible = ref(false);
|
||||||
|
const selectedCustomerId = ref("");
|
||||||
|
const selectedCustomer = ref<Customer>();
|
||||||
|
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||||
|
const selectedRegistrationEventIdList = ref<string[]>([]);
|
||||||
|
const recentRecordList = ref<RegistrationEventRecord[]>([]);
|
||||||
|
const registeredAtText = ref("");
|
||||||
|
const registeredAtDraftText = ref("");
|
||||||
|
const registeredAtPickerVisible = ref(false);
|
||||||
|
const note = ref("");
|
||||||
|
const recordPhotoInputRef = ref<HTMLInputElement>();
|
||||||
|
const photoItemList = ref<EditablePhoto[]>([]);
|
||||||
|
const scannerVisible = ref(false);
|
||||||
|
const scanStatus = ref("点击扫码开始识别。");
|
||||||
|
const saving = ref(false);
|
||||||
|
const editingRecord = ref<RegistrationEventRecord>();
|
||||||
|
let latestDecodedText = "";
|
||||||
|
|
||||||
|
type EditablePhoto = {
|
||||||
|
attachment?: Partial<Attachment>;
|
||||||
|
tempFileId?: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeId = computed(() => String(route.params.id || ""));
|
||||||
|
const isEditMode = computed(() => !!routeId.value);
|
||||||
|
const pageAccess = computed(() => isEditMode.value ? gaoPermissionCodes.registrationEventRecord.update : gaoPermissionCodes.registrationEventRecord.create);
|
||||||
|
const saveButtonText = computed(() => isEditMode.value ? "保存记录" : "提交");
|
||||||
|
const canSubmit = computed(() => !!selectedCustomer.value?.id && 0 < selectedRegistrationEventIdList.value.length);
|
||||||
|
const customerDisplayText = computed(() => selectedCustomer.value?.name || selectedCustomer.value?.code || customerCode.value);
|
||||||
|
const photoList = computed<string[]>({
|
||||||
|
get() {
|
||||||
|
return photoItemList.value.map(item => item.url);
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
const urlSet = new Set(value);
|
||||||
|
photoItemList.value = photoItemList.value.filter(item => urlSet.has(item.url));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const registrationEventNameMap = computed(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const item of registrationEventList.value) {
|
||||||
|
if (item.id) {
|
||||||
|
map.set(item.id, item.name || "未命名事件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 页面数据 ----------
|
||||||
|
|
||||||
|
async function loadPageData(): Promise<void> {
|
||||||
|
registrationEventList.value = await RegistrationEventAPI.enabledList();
|
||||||
|
if (isEditMode.value) {
|
||||||
|
await loadRecordForEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedRegistrationEventIdList.value = registrationEventList.value.slice(0, 1).map(item => item.id).filter((item): item is string => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecordForEdit(): Promise<void> {
|
||||||
|
const record = await RegistrationEventRecordAPI.detail(routeId.value);
|
||||||
|
editingRecord.value = record;
|
||||||
|
appendRecordRegistrationEvent(record);
|
||||||
|
selectedCustomer.value = record.customer;
|
||||||
|
customerCode.value = record.customer?.code || record.customerId || "";
|
||||||
|
selectedCustomerId.value = record.customer?.id || "";
|
||||||
|
selectedRegistrationEventIdList.value = record.registrationEventId ? [record.registrationEventId] : [];
|
||||||
|
registeredAtText.value = record.registeredAt ? Time.formatDateTime(new Date(record.registeredAt)) : "";
|
||||||
|
note.value = record.remark || "";
|
||||||
|
photoItemList.value = (record.attachmentList || [])
|
||||||
|
.filter(item => item.attachType === "PHOTO" || !item.attachType)
|
||||||
|
.filter(item => !!item.id)
|
||||||
|
.map(item => ({
|
||||||
|
attachment: item,
|
||||||
|
url: AttachmentAPI.getReadURL(item.id || "")
|
||||||
|
}));
|
||||||
|
await loadRecentRecordList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRecordRegistrationEvent(record: RegistrationEventRecord): void {
|
||||||
|
if (!record.registrationEvent?.id || registrationEventList.value.some(item => item.id === record.registrationEvent?.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
registrationEventList.value = [record.registrationEvent, ...registrationEventList.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 扫码 ----------
|
||||||
|
|
||||||
|
async function toggleScanner(): Promise<void> {
|
||||||
|
scannerVisible.value = !scannerVisible.value;
|
||||||
|
if (scannerVisible.value) {
|
||||||
|
latestDecodedText = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecodedText(rawText: string): Promise<void> {
|
||||||
|
const normalizedText = rawText.trim();
|
||||||
|
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
latestDecodedText = normalizedText;
|
||||||
|
scanCode.value = normalizedText;
|
||||||
|
await settingStore.playScanSuccessSound();
|
||||||
|
await applyScanCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyScanCode(): Promise<void> {
|
||||||
|
customerCode.value = scanCode.value.trim();
|
||||||
|
scanStatus.value = `已识别 ${customerCode.value},正在加载客户档案。`;
|
||||||
|
await loadSelectedCustomer();
|
||||||
|
if (!selectedCustomer.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanStatus.value = `已识别 ${selectedCustomer.value.code || customerCode.value},已自动选中 ${selectedCustomer.value.name || "未命名客户"}。`;
|
||||||
|
scannerVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanStatusChange(value: string): void {
|
||||||
|
scanStatus.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 客户 ----------
|
||||||
|
|
||||||
|
async function handleCustomerChange(id: string): Promise<void> {
|
||||||
|
const customer = await CustomerAPI.detail(id);
|
||||||
|
selectedCustomer.value = customer;
|
||||||
|
selectedCustomerId.value = customer.id || "";
|
||||||
|
customerCode.value = customer.code || "";
|
||||||
|
scanCode.value = customer.code || "";
|
||||||
|
await loadRecentRecordList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goRecordDetail(id: string): Promise<void> {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await router.push(`/management/registration-event-records/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSelectedCustomer(): Promise<void> {
|
||||||
|
const code = customerCode.value.trim();
|
||||||
|
if (!code) {
|
||||||
|
selectedCustomer.value = undefined;
|
||||||
|
selectedCustomerId.value = "";
|
||||||
|
recentRecordList.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await CustomerAPI.nullableDetailByCode(code);
|
||||||
|
if (!customer) {
|
||||||
|
selectedCustomer.value = undefined;
|
||||||
|
selectedCustomerId.value = "";
|
||||||
|
recentRecordList.value = [];
|
||||||
|
scanStatus.value = `客户 ${code} 不存在,请先建档。`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCustomer.value = customer;
|
||||||
|
selectedCustomerId.value = customer.id || "";
|
||||||
|
await loadRecentRecordList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSubmitCustomer(): Promise<Customer | undefined> {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCustomer.value?.id) {
|
||||||
|
return selectedCustomer.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast("请先选择或扫码已有客户", "warning");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecentRecordList(): Promise<void> {
|
||||||
|
const customerId = selectedCustomer.value?.id;
|
||||||
|
if (!customerId) {
|
||||||
|
recentRecordList.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endRegisteredAt = Time.toTimestamp(new Date());
|
||||||
|
recentRecordList.value = (await RegistrationEventRecordAPI.rangeList({
|
||||||
|
customerId,
|
||||||
|
beginRegisteredAt: 0,
|
||||||
|
endRegisteredAt
|
||||||
|
}))
|
||||||
|
.sort((left, right) => (right.registeredAt || 0) - (left.registeredAt || 0))
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordRegistrationEventName(record: RegistrationEventRecord): string {
|
||||||
|
return record.registrationEvent?.name || (record.registrationEventId ? registrationEventNameMap.value.get(record.registrationEventId) : "") || "未知事件";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordPhotoURL(record: RegistrationEventRecord): string {
|
||||||
|
return getRegistrationEventRecordPhotoURL(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 照片 ----------
|
||||||
|
|
||||||
|
function capturePhoto(): void {
|
||||||
|
recordPhotoInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRecordPhotoSelected(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const fileList = Array.from(input.files || []);
|
||||||
|
if (!fileList.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const compressedFileList = await compressUploadImageList(fileList);
|
||||||
|
const tempFileList = await AttachmentAPI.uploadTemp(compressedFileList);
|
||||||
|
for (const item of tempFileList) {
|
||||||
|
if (!item.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
photoItemList.value.push({
|
||||||
|
tempFileId: item.id,
|
||||||
|
url: AttachmentAPI.getTempReadURL(item.id)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 提交 ----------
|
||||||
|
|
||||||
|
function openRegisteredAtPicker(): void {
|
||||||
|
registeredAtDraftText.value = registeredAtText.value || Time.formatDateTime(new Date());
|
||||||
|
registeredAtPickerVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRegisteredAt(): void {
|
||||||
|
registeredAtText.value = registeredAtDraftText.value;
|
||||||
|
registeredAtPickerVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRecord(): Promise<void> {
|
||||||
|
const submitCustomer = await resolveSubmitCustomer();
|
||||||
|
if (!submitCustomer?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (isEditMode.value) {
|
||||||
|
await updateRecord();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const registrationEventId of selectedRegistrationEventIdList.value) {
|
||||||
|
await RegistrationEventRecordAPI.create({
|
||||||
|
registrationEventId,
|
||||||
|
customerId: submitCustomer.id,
|
||||||
|
remark: note.value || "客户现场登记",
|
||||||
|
registeredAt: buildRegisteredAt(),
|
||||||
|
attachmentIdList: buildTempFileIdList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
note.value = "";
|
||||||
|
photoItemList.value = [];
|
||||||
|
await loadRecentRecordList();
|
||||||
|
await settingStore.playRegisterSuccessSound();
|
||||||
|
showToast("登记已提交");
|
||||||
|
await router.replace("/customers");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(): boolean {
|
||||||
|
if (!selectedCustomer.value?.id) {
|
||||||
|
showToast("请先选择或扫码客户", "warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!selectedRegistrationEventIdList.value.length) {
|
||||||
|
showToast("请至少选择一个登记事件", "warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRecord(): Promise<void> {
|
||||||
|
const submitCustomer = selectedCustomer.value;
|
||||||
|
if (!editingRecord.value?.id || !submitCustomer?.id) {
|
||||||
|
showToast("登记事件记录不存在", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RegistrationEventRecordAPI.update({
|
||||||
|
...editingRecord.value,
|
||||||
|
customerId: submitCustomer.id,
|
||||||
|
registrationEventId: selectedRegistrationEventIdList.value[0],
|
||||||
|
remark: note.value,
|
||||||
|
registeredAt: buildRegisteredAt(),
|
||||||
|
attachmentList: buildPhotoAttachmentList()
|
||||||
|
});
|
||||||
|
showToast("记录已保存");
|
||||||
|
await router.replace(`/management/registration-event-records/${editingRecord.value.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTempFileIdList(): string[] {
|
||||||
|
return photoItemList.value.map(item => item.tempFileId).filter((item): item is string => !!item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPhotoAttachmentList(): Array<Partial<Attachment>> {
|
||||||
|
return photoItemList.value.map((item) => {
|
||||||
|
if (item.attachment) {
|
||||||
|
return item.attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tempFileId: item.tempFileId,
|
||||||
|
attachType: "PHOTO"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRegisteredAt(): number {
|
||||||
|
return Time.toTimestamp(parseDateTime(registeredAtText.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||||
|
Toast({
|
||||||
|
theme,
|
||||||
|
direction: "row",
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: number): string {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = Time.toDateObject(value);
|
||||||
|
if (!date) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return Time.formatDateTime(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateTime(value: string): Date {
|
||||||
|
if (!value) {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [dateText, timeText = "00:00"] = value.split(" ");
|
||||||
|
const [year, month, day] = dateText.split("-").map(Number);
|
||||||
|
const [hour, minute] = timeText.split(":").map(Number);
|
||||||
|
return new Date(year, month - 1, day, hour || 0, minute || 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadPageData);
|
||||||
|
|
||||||
|
watch(customerCode, async (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanCode.value = value;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.registration-record-edit {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.form,
|
||||||
|
.record-panel {
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: .5rem 0;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
|
||||||
|
.customer {
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-scanner-wrapper,
|
||||||
|
.photo-part {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-profile {
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-event-selector {
|
||||||
|
padding: 0 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-photo {
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #52607a;
|
||||||
|
margin: 0 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
background: #f7faff;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.registration-record-edit {
|
||||||
|
:deep(.form-item .t-form__label),
|
||||||
|
.selector-text p,
|
||||||
|
.empty-block {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form,
|
||||||
|
.record-panel {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.textarea .t-textarea__inner) {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, .72);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
background: rgba(30, 41, 59, .9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-text strong {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-action {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="pageRef" v-can="gaoPermissionCodes.registrationEventRecord.read" class="registration-record-list">
|
||||||
|
<div class="search-bar">
|
||||||
|
<t-input
|
||||||
|
v-model.trim="keyword"
|
||||||
|
class="search-input"
|
||||||
|
type="search"
|
||||||
|
borderless
|
||||||
|
placeholder="搜索客户、编码、事件、备注、登记人"
|
||||||
|
/>
|
||||||
|
<div class="filter-line">
|
||||||
|
<t-dropdown-menu class="filter-menu">
|
||||||
|
<t-dropdown-item
|
||||||
|
v-model="dateFilterType"
|
||||||
|
:label="dateFilterLabel"
|
||||||
|
:options="dateOptions"
|
||||||
|
@change="selectDateFilter"
|
||||||
|
/>
|
||||||
|
<t-dropdown-item
|
||||||
|
v-model="actionFilter"
|
||||||
|
:label="eventFilterLabel"
|
||||||
|
options-columns="2"
|
||||||
|
:options="actionOptions"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
</t-dropdown-menu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<t-cell-group v-if="isCustomRange" class="time-range" theme="card">
|
||||||
|
<t-cell
|
||||||
|
title="开始时间"
|
||||||
|
:note="dateRange[0] || '-'"
|
||||||
|
@click="startVisible = true"
|
||||||
|
/>
|
||||||
|
<t-cell
|
||||||
|
title="结束时间"
|
||||||
|
:note="dateRange[1] || '-'"
|
||||||
|
@click="endVisible = true"
|
||||||
|
/>
|
||||||
|
</t-cell-group>
|
||||||
|
<div class="summary">
|
||||||
|
<div v-text="summaryText" />
|
||||||
|
<div v-text="summaryBehaviorText" />
|
||||||
|
<div v-text="summaryTotalText" />
|
||||||
|
</div>
|
||||||
|
<t-cell-group
|
||||||
|
v-if="recordList.length"
|
||||||
|
class="record-list"
|
||||||
|
theme="card"
|
||||||
|
>
|
||||||
|
<t-swipe-cell
|
||||||
|
v-for="item in recordList"
|
||||||
|
:key="item.id"
|
||||||
|
class="item"
|
||||||
|
>
|
||||||
|
<t-cell
|
||||||
|
:title="getRecordRegistrationEventName(item)"
|
||||||
|
:description="getRecordCustomerName(item)"
|
||||||
|
:note="formatTime(item.registeredAt)"
|
||||||
|
arrow
|
||||||
|
hover
|
||||||
|
@click="goDetail(item.id || '')"
|
||||||
|
>
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-image
|
||||||
|
v-if="getRecordPhotoURL(item)"
|
||||||
|
:src="getRecordPhotoURL(item)"
|
||||||
|
:alt="getRecordRegistrationEventName(item)"
|
||||||
|
fit="cover"
|
||||||
|
class="photo"
|
||||||
|
/>
|
||||||
|
<div v-else class="photo fallback" v-text="getRecordCustomerInitial(item)" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<template v-if="canDeleteRecord" #right>
|
||||||
|
<t-button
|
||||||
|
class="delete"
|
||||||
|
theme="danger"
|
||||||
|
:loading="deletingId === item.id"
|
||||||
|
@click.stop="confirmRemoveRecord(item)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</t-button>
|
||||||
|
</template>
|
||||||
|
</t-swipe-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<div v-else class="empty-block" v-text="loading ? '正在加载登记事件记录' : '暂无登记事件记录'" />
|
||||||
|
<div v-if="recordList.length" class="load-state" v-text="loadStateText" />
|
||||||
|
</div>
|
||||||
|
<t-button
|
||||||
|
v-can="gaoPermissionCodes.registrationEventRecord.read"
|
||||||
|
class="export"
|
||||||
|
theme="primary"
|
||||||
|
:loading="exporting"
|
||||||
|
@click="exportExcel"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="download" />
|
||||||
|
</template>
|
||||||
|
导出 Excel
|
||||||
|
</t-button>
|
||||||
|
<t-popup v-model="startVisible" placement="bottom">
|
||||||
|
<t-date-time-picker
|
||||||
|
:default-value="dateRange[0]"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
:mode="['date', 'minute']"
|
||||||
|
title="开始时间"
|
||||||
|
v-model="dateRange[0]"
|
||||||
|
@confirm="startVisible = false"
|
||||||
|
/>
|
||||||
|
</t-popup>
|
||||||
|
<t-popup v-model="endVisible" placement="bottom">
|
||||||
|
<t-date-time-picker
|
||||||
|
:default-value="dateRange[1]"
|
||||||
|
:mode="['date', 'minute']"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
title="结束时间"
|
||||||
|
v-model="dateRange[1]"
|
||||||
|
@confirm="endVisible = false"
|
||||||
|
/>
|
||||||
|
</t-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogPlugin, Toast, type DropdownValue } from "tdesign-mobile-vue";
|
||||||
|
import { AttachmentAPI, Scroller, Time, Toolkit, useUserStore } from "timi-web";
|
||||||
|
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||||
|
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
|
||||||
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
|
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||||
|
import type { RegistrationEventRecordListItemView, RegistrationEventRecordRangeReq } from "@/types/RegistrationEventRecord";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
type DateFilterType = "today" | "yesterday" | "beforeYesterday" | "lastWeek" | "all" | "custom";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "RegistrationEventRecordList"
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
const scrollListenerName = "RegistrationEventRecordList";
|
||||||
|
const router = useRouter();
|
||||||
|
const navBarStore = useNavBarStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
|
||||||
|
{ label: "今天", value: "today" },
|
||||||
|
{ label: "昨天", value: "yesterday" },
|
||||||
|
{ label: "前天", value: "beforeYesterday" },
|
||||||
|
{ label: "上周", value: "lastWeek" },
|
||||||
|
{ label: "全部", value: "all" },
|
||||||
|
{ label: "自定义范围", value: "custom" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- 筛选 ----------
|
||||||
|
|
||||||
|
const keyword = ref("");
|
||||||
|
const dateFilterType = ref<DateFilterType>("today");
|
||||||
|
const dateRange = ref(buildPresetRange("today"));
|
||||||
|
const actionFilter = ref<string[]>([]);
|
||||||
|
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||||
|
const startVisible = ref(false);
|
||||||
|
const endVisible = ref(false);
|
||||||
|
const pageRef = ref<HTMLElement>();
|
||||||
|
let keywordTimer: number | undefined;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
function getPageScrollElement(): HTMLElement | null {
|
||||||
|
return pageRef.value?.closest(".pages") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCreateRecord = computed(() => userStore.hasPermissions(gaoPermissionCodes.registrationEventRecord.create));
|
||||||
|
const isCustomRange = computed(() => dateFilterType.value === "custom");
|
||||||
|
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "今天");
|
||||||
|
const actionOptions = computed(() => registrationEventList.value.map(item => ({
|
||||||
|
label: item.name || "未命名事件",
|
||||||
|
value: item.id || ""
|
||||||
|
})).filter(item => item.value));
|
||||||
|
const eventFilterLabel = computed(() => {
|
||||||
|
if (!actionFilter.value.length || actionFilter.value.length === registrationEventList.value.length) {
|
||||||
|
return "全部事件";
|
||||||
|
}
|
||||||
|
|
||||||
|
return registrationEventList.value
|
||||||
|
.filter(item => item.id && actionFilter.value.includes(item.id))
|
||||||
|
.map(item => item.name || "未命名事件")
|
||||||
|
.join("、");
|
||||||
|
});
|
||||||
|
const summaryBehaviorText = computed(() => `事件:${eventFilterLabel.value}`);
|
||||||
|
const canDeleteRecord = computed(() => userStore.hasPermissions(gaoPermissionCodes.registrationEventRecord.delete));
|
||||||
|
const summaryText = computed(() => {
|
||||||
|
if (!isCustomRange.value) {
|
||||||
|
const current = dateOptions.find(item => item.value === dateFilterType.value);
|
||||||
|
return `范围:${current?.label || "今天"}|${dateRange.value[0] || "不限"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `范围:${dateRange.value[0] || "-"} ~ ${dateRange.value[1] || "-"}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(keyword, () => {
|
||||||
|
if (!initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.clearTimeout(keywordTimer);
|
||||||
|
keywordTimer = window.setTimeout(async () => {
|
||||||
|
await reloadRecords();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(actionFilter, async () => {
|
||||||
|
if (!initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await reloadRecords();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(dateRange, async (value) => {
|
||||||
|
if (!initialized || dateFilterType.value !== "custom") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value[0] && value[1] && value[1] < value[0]) {
|
||||||
|
dateRange.value = [value[1], value[0]];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reloadRecords();
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// ---------- 列表分页 ----------
|
||||||
|
|
||||||
|
const pageIndex = ref(0);
|
||||||
|
const totalCount = ref(0);
|
||||||
|
const recordList = ref<RegistrationEventRecordListItemView[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const noMore = ref(false);
|
||||||
|
let loadToken = 0;
|
||||||
|
|
||||||
|
const summaryTotalText = computed(() => `共 ${totalCount.value} 条数据,已加载 ${recordList.value.length} 条`);
|
||||||
|
const loadStateText = computed(() => {
|
||||||
|
if (loading.value) {
|
||||||
|
return "正在加载更多";
|
||||||
|
}
|
||||||
|
return noMore.value ? "没有更多记录" : "上拉加载更多";
|
||||||
|
});
|
||||||
|
|
||||||
|
async function reloadRecords(): Promise<void> {
|
||||||
|
pageIndex.value = 0;
|
||||||
|
totalCount.value = 0;
|
||||||
|
recordList.value = [];
|
||||||
|
noMore.value = false;
|
||||||
|
await loadRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecords(): Promise<void> {
|
||||||
|
if (loading.value || noMore.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = loadToken + 1;
|
||||||
|
loadToken = token;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await RegistrationEventRecordAPI.rangePage({
|
||||||
|
index: pageIndex.value,
|
||||||
|
size: pageSize,
|
||||||
|
equalsExample: buildRangeReq()
|
||||||
|
});
|
||||||
|
if (token !== loadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordList.value = recordList.value.concat(result.list || []);
|
||||||
|
totalCount.value = result.total || 0;
|
||||||
|
pageIndex.value += 1;
|
||||||
|
noMore.value = recordList.value.length >= totalCount.value || (result.list || []).length < pageSize;
|
||||||
|
} finally {
|
||||||
|
if (token === loadToken) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 操作 ----------
|
||||||
|
|
||||||
|
const exporting = ref(false);
|
||||||
|
const deletingId = ref("");
|
||||||
|
|
||||||
|
async function loadActions(): Promise<void> {
|
||||||
|
registrationEventList.value = await RegistrationEventAPI.enabledList();
|
||||||
|
actionFilter.value = registrationEventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectDateFilter(type: DropdownValue): Promise<void> {
|
||||||
|
if (Array.isArray(type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dateFilterType.value = String(type) as DateFilterType;
|
||||||
|
if (dateFilterType.value !== "custom") {
|
||||||
|
dateRange.value = buildPresetRange(dateFilterType.value);
|
||||||
|
await reloadRecords();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportExcel(): Promise<void> {
|
||||||
|
if (exporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exporting.value = true;
|
||||||
|
try {
|
||||||
|
const blob = await RegistrationEventRecordAPI.rangeExport(buildRangeReq());
|
||||||
|
Toolkit.downloadBlob(blob, `登记事件记录-${Time.formatDate(new Date())}.xlsx`);
|
||||||
|
showToast("导出已开始");
|
||||||
|
} finally {
|
||||||
|
exporting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goDetail(id: string): Promise<void> {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await router.push(`/management/registration-event-records/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goCreate(): Promise<void> {
|
||||||
|
await router.push("/management/registration-event-records/create");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoveRecord(record: RegistrationEventRecordListItemView): Promise<void> {
|
||||||
|
if (!record.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordId = record.id;
|
||||||
|
const dialog = DialogPlugin.confirm({
|
||||||
|
title: "删除登记记录",
|
||||||
|
content: `确定删除 ${getRecordCustomerName(record)} 的 ${getRecordRegistrationEventName(record)} 记录吗?删除后无法恢复。`,
|
||||||
|
cancelBtn: "取消",
|
||||||
|
confirmBtn: "删除",
|
||||||
|
onCancel() {
|
||||||
|
dialog.destroy();
|
||||||
|
},
|
||||||
|
async onConfirm() {
|
||||||
|
dialog.destroy();
|
||||||
|
await removeRecord(recordId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRecord(id: string): Promise<void> {
|
||||||
|
if (deletingId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingId.value = id;
|
||||||
|
try {
|
||||||
|
await RegistrationEventRecordAPI.remove(id);
|
||||||
|
recordList.value = recordList.value.filter(item => item.id !== id);
|
||||||
|
totalCount.value = Math.max(0, totalCount.value - 1);
|
||||||
|
showToast("登记记录已删除");
|
||||||
|
} finally {
|
||||||
|
deletingId.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 格式化 ----------
|
||||||
|
|
||||||
|
function getRecordRegistrationEventName(record: RegistrationEventRecordListItemView): string {
|
||||||
|
return record.registrationEventName || "未知事件";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordCustomerName(record: RegistrationEventRecordListItemView): string {
|
||||||
|
return record.customerName || "未知客户";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordPhotoURL(record: RegistrationEventRecordListItemView): string {
|
||||||
|
if (!record.customerPhotoAttachmentId) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return AttachmentAPI.getReadURL(record.customerPhotoAttachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordCustomerInitial(record: RegistrationEventRecordListItemView): string {
|
||||||
|
return (record.customerName || "客").slice(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: number): string {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = Time.toDateObject(value);
|
||||||
|
if (!date) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return Time.formatDateTime(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRangeReq(): RegistrationEventRecordRangeReq {
|
||||||
|
const [beginRegisteredAt, endRegisteredAt] = buildUnixRange();
|
||||||
|
const selectedAllEvent = !actionFilter.value.length || actionFilter.value.length === registrationEventList.value.length;
|
||||||
|
return {
|
||||||
|
beginRegisteredAt,
|
||||||
|
endRegisteredAt,
|
||||||
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
registrationEventIdList: selectedAllEvent ? undefined : actionFilter.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
|
||||||
|
if (type === "all") {
|
||||||
|
return ["", ""];
|
||||||
|
}
|
||||||
|
if (type === "lastWeek") {
|
||||||
|
const current = new Date();
|
||||||
|
current.setHours(0, 0, 0, 0);
|
||||||
|
const day = current.getDay() || 7;
|
||||||
|
const thisWeekMonday = new Date(current);
|
||||||
|
thisWeekMonday.setDate(current.getDate() - day + 1);
|
||||||
|
const lastWeekMonday = new Date(thisWeekMonday);
|
||||||
|
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
|
||||||
|
const lastWeekSunday = new Date(thisWeekMonday);
|
||||||
|
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
|
||||||
|
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek">, number> = {
|
||||||
|
today: 0,
|
||||||
|
yesterday: 1,
|
||||||
|
beforeYesterday: 2
|
||||||
|
};
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
date.setDate(date.getDate() - offsetMap[type]);
|
||||||
|
const value = `${Time.formatDate(date)} 00:00`;
|
||||||
|
return [value, `${Time.formatDate(date)} 23:59`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUnixRange(): [number, number] {
|
||||||
|
if (dateFilterType.value === "all") {
|
||||||
|
return [0, Time.toTimestamp(new Date())];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Time.toTimestamp(parseDateTime(dateRange.value[0], false)),
|
||||||
|
Time.toTimestamp(parseDateTime(dateRange.value[1], true))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateTime(value: string, isEnd: boolean): Date {
|
||||||
|
if (!value) {
|
||||||
|
return isEnd ? new Date() : new Date(0);
|
||||||
|
}
|
||||||
|
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
|
||||||
|
const [year, month, day] = dateText.split("-").map(Number);
|
||||||
|
const [hour, minute] = timeText.split(":").map(Number);
|
||||||
|
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||||
|
Toast({
|
||||||
|
message,
|
||||||
|
theme
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
navBarStore.clearRight();
|
||||||
|
if (canCreateRecord.value) {
|
||||||
|
navBarStore.setRightText("添加", "var(--td-brand-color)", goCreate);
|
||||||
|
}
|
||||||
|
await loadActions();
|
||||||
|
initialized = true;
|
||||||
|
await reloadRecords();
|
||||||
|
Scroller.addBottomListener(scrollListenerName, loadRecords, {
|
||||||
|
canLoad: () => !loading.value && !noMore.value,
|
||||||
|
getElement: getPageScrollElement
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
navBarStore.clearRight();
|
||||||
|
window.clearTimeout(keywordTimer);
|
||||||
|
Scroller.removeListener(scrollListenerName);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.registration-record-list {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
padding: calc(var(--app-nav-height)) 0 6rem 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
top: var(--app-nav-height);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
position: sticky;
|
||||||
|
background: #F5F5F8;
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
--td-input-vertical-padding: 8px;
|
||||||
|
|
||||||
|
border: 1px solid rgba(148, 163, 184, .28);
|
||||||
|
z-index: 1;
|
||||||
|
background: #f8fbff;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-menu {
|
||||||
|
--td-spacer-1: 6px;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: -1rem;
|
||||||
|
padding-top: .8rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(148, 163, 184, .18);
|
||||||
|
border-radius: 0 0 1rem 1rem;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-dropdown-menu__item) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-dropdown-menu__title) {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .8125rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-list {
|
||||||
|
|
||||||
|
.item {
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
|
||||||
|
:deep(.t-cell:after) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo {
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: .875rem;
|
||||||
|
|
||||||
|
&.fallback {
|
||||||
|
color: #2563eb;
|
||||||
|
display: flex;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
background: #dbeafe;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block,
|
||||||
|
.load-state {
|
||||||
|
color: #52607a;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
z-index: 20;
|
||||||
|
position: fixed;
|
||||||
|
box-shadow: 0 -.375rem 1.25rem rgba(15, 23, 42, .08);
|
||||||
|
backdrop-filter: blur(.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.registration-record-list {
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
background: #10161f;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
background: rgba(15, 23, 42, .72);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-menu {
|
||||||
|
background: #172033;
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block,
|
||||||
|
.load-state,
|
||||||
|
.summary {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 -.375rem 1.25rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div v-role="permissionManagementRoleAccess" v-can="pageAccess" class="role-edit">
|
||||||
|
<t-form class="form" label-align="left">
|
||||||
|
<t-form-item label="名称">
|
||||||
|
<t-input v-model.trim="formModel.name" placeholder="角色名称" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="代码">
|
||||||
|
<t-input v-model.trim="formModel.code" placeholder="如 front-desk" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="模块">
|
||||||
|
<t-input v-model.trim="formModel.moduleCode" placeholder="模块代码" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item label="说明">
|
||||||
|
<t-textarea v-model.trim="formModel.description" class="textarea" placeholder="角色说明" autosize borderless />
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
<section class="children">
|
||||||
|
<div class="title">
|
||||||
|
<span>子角色</span>
|
||||||
|
<span class="hint">父角色会继承子角色权限</span>
|
||||||
|
</div>
|
||||||
|
<t-checkbox-group class="group" v-model="formModel.childRoleIdList">
|
||||||
|
<t-checkbox
|
||||||
|
class="item bg-transparent"
|
||||||
|
v-for="item in childOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:value="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
icon="line"
|
||||||
|
borderless
|
||||||
|
default-checked
|
||||||
|
/>
|
||||||
|
</t-checkbox-group>
|
||||||
|
</section>
|
||||||
|
<t-button v-can="savePermissionCode" class="save" theme="primary" @click="saveRole">保存</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { gaoModuleCode, usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||||
|
import { corePermissionCodes, permissionManagementRoleAccess } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "RoleEdit"
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const permissionStore = usePermissionSettingsStore();
|
||||||
|
const routeId = computed(() => String(route.params.id || ""));
|
||||||
|
const isCreateMode = computed(() => !routeId.value);
|
||||||
|
const savePermissionCode = computed(() => isCreateMode.value ? corePermissionCodes.role.create : corePermissionCodes.role.update);
|
||||||
|
const pageAccess = computed(() => ({
|
||||||
|
value: [corePermissionCodes.role.read, corePermissionCodes.permission.read, savePermissionCode.value],
|
||||||
|
mode: "and" as const
|
||||||
|
}));
|
||||||
|
const formModel = reactive({
|
||||||
|
id: "",
|
||||||
|
moduleCode: gaoModuleCode,
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
childRoleIdList: [] as string[]
|
||||||
|
});
|
||||||
|
const childOptions = computed(() => permissionStore.roleList.filter(item => item.id !== formModel.id && !hasChildRole(item.id, formModel.id)));
|
||||||
|
|
||||||
|
function hasChildRole(roleId: string, targetRoleId: string, visitedRoleIds = new Set<string>()): boolean {
|
||||||
|
if (visitedRoleIds.has(roleId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = permissionStore.getRoleById(roleId);
|
||||||
|
if (!current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
visitedRoleIds.add(roleId);
|
||||||
|
return current.childRoleIdList.some(childRoleId => childRoleId === targetRoleId || hasChildRole(childRoleId, targetRoleId, visitedRoleIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillForm(): Promise<void> {
|
||||||
|
await permissionStore.loadAll();
|
||||||
|
const current = await permissionStore.loadRoleDetail(routeId.value);
|
||||||
|
Object.assign(formModel, {
|
||||||
|
id: current.id,
|
||||||
|
moduleCode: current.moduleCode || gaoModuleCode,
|
||||||
|
code: current.code,
|
||||||
|
name: current.name,
|
||||||
|
description: current.description,
|
||||||
|
childRoleIdList: [...current.childRoleIdList]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRole(): Promise<void> {
|
||||||
|
const role = await permissionStore.upsertRole({
|
||||||
|
id: formModel.id,
|
||||||
|
moduleCode: gaoModuleCode,
|
||||||
|
code: formModel.code,
|
||||||
|
name: formModel.name,
|
||||||
|
description: formModel.description,
|
||||||
|
permissionIdList: permissionStore.getRoleById(formModel.id)?.permissionIdList || [],
|
||||||
|
childRoleIdList: [...formModel.childRoleIdList]
|
||||||
|
}, isCreateMode.value);
|
||||||
|
Toast({
|
||||||
|
theme: "success",
|
||||||
|
direction: "row",
|
||||||
|
message: "角色已保存"
|
||||||
|
});
|
||||||
|
await router.push(isCreateMode.value ? "/management/permissions/roles" : `/management/permissions/roles/${role?.id || formModel.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fillForm();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.role-edit {
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 5rem 0;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
border-radius: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
gap: .25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
span:first-child {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
--td-checkbox-vertical-padding: 10px;
|
||||||
|
|
||||||
|
background: #FFF;
|
||||||
|
border-radius: 1rem;
|
||||||
|
|
||||||
|
.item:not(:last-child)::after {
|
||||||
|
content: ' ';
|
||||||
|
left: 16px;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-bottom: 1px solid var(--td-component-stroke, var(--td-gray-color-3, #e7e7e7));
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.role-edit {
|
||||||
|
.children {
|
||||||
|
.title .hint {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group .card {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, .72);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div v-role="permissionManagementRoleAccess" v-can="{ value: [corePermissionCodes.role.read, corePermissionCodes.permission.read], mode: 'and' }" class="role-list">
|
||||||
|
<t-cell-group v-if="sortedRoles.length" theme="card">
|
||||||
|
<t-swipe-cell
|
||||||
|
v-for="item in sortedRoles"
|
||||||
|
:key="item.id"
|
||||||
|
class="item"
|
||||||
|
>
|
||||||
|
<t-cell
|
||||||
|
:title="item.name"
|
||||||
|
:note="item.code"
|
||||||
|
arrow
|
||||||
|
hover
|
||||||
|
@click="goEdit(item.id)"
|
||||||
|
/>
|
||||||
|
<template #right>
|
||||||
|
<t-button v-can="corePermissionCodes.role.delete" class="delete" theme="danger" @click="removeRole(item.id)">删除</t-button>
|
||||||
|
</template>
|
||||||
|
</t-swipe-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<t-empty v-else-if="!permissionStore.loading" description="暂无角色" />
|
||||||
|
<t-button v-can="corePermissionCodes.role.create" class="add" theme="primary" type="button" @click="goCreate">
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="add" />
|
||||||
|
</template>
|
||||||
|
新增角色
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Role } from "@/store/permissionSettingsStore";
|
||||||
|
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||||
|
import { corePermissionCodes, permissionManagementRoleAccess } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "RoleList"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const permissionStore = usePermissionSettingsStore();
|
||||||
|
const sortedRoles = computed<Role[]>(() => [...permissionStore.roleList].sort((left, right) => (left.name || "").localeCompare(right.name || "")));
|
||||||
|
|
||||||
|
async function goCreate(): Promise<void> {
|
||||||
|
await router.push("/management/permissions/roles/create");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goEdit(id: string): Promise<void> {
|
||||||
|
await router.push(`/management/permissions/roles/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRole(id: string): Promise<void> {
|
||||||
|
await permissionStore.removeRole(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await permissionStore.loadAll();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.role-list {
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
|
||||||
|
:deep(.t-cell:after) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
<template>
|
||||||
|
<div v-role="permissionManagementRoleAccess" v-can="{ value: [corePermissionCodes.role.read, corePermissionCodes.permission.read], mode: 'and' }" class="role-permission-assign">
|
||||||
|
<div class="header bg-white">
|
||||||
|
<t-dropdown-menu class="role-selector">
|
||||||
|
<t-dropdown-item
|
||||||
|
:label="activeRoleLabel"
|
||||||
|
:options="roleOptions"
|
||||||
|
:value="activeRoleId"
|
||||||
|
borderless
|
||||||
|
@change="handleRoleChange"
|
||||||
|
/>
|
||||||
|
</t-dropdown-menu>
|
||||||
|
<div class="inherit">
|
||||||
|
<span class="label">显示子角色权限</span>
|
||||||
|
<t-switch class="switch" v-model="showChildRolePermissions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<t-cell-group v-if="activeRole && permissionStore.permissionList.length" theme="card">
|
||||||
|
<t-cell
|
||||||
|
v-for="item in permissionStore.permissionList"
|
||||||
|
:key="item.id"
|
||||||
|
:title="item.name"
|
||||||
|
:class="{ inherited: isPermissionInherited(item.id) }"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<span class="code" v-text="item.code"></span>
|
||||||
|
</template>
|
||||||
|
<template #right-icon>
|
||||||
|
<t-switch
|
||||||
|
:disabled="!canUpdateRole || isPermissionInherited(item.id)"
|
||||||
|
:model-value="isPermissionEnabled(item.id)"
|
||||||
|
@change="value => setPermissionChecked(item.id, value === true)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<t-empty v-else-if="!permissionStore.loading" description="暂无可授权权限" />
|
||||||
|
<t-button
|
||||||
|
v-can="corePermissionCodes.role.update"
|
||||||
|
class="save"
|
||||||
|
theme="primary"
|
||||||
|
@click="savePermissionList"
|
||||||
|
>
|
||||||
|
保存授权
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownOption, DropdownValue } from "tdesign-mobile-vue";
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { useUserStore } from "timi-web";
|
||||||
|
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||||
|
import { corePermissionCodes, permissionManagementRoleAccess } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "RolePermissionAssign"
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissionStore = usePermissionSettingsStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const activeRoleId = ref(String(route.query.roleId || ""));
|
||||||
|
const checkedPermissionIds = ref<string[]>([]);
|
||||||
|
const showChildRolePermissions = ref(false);
|
||||||
|
let permissionSyncSeq = 0;
|
||||||
|
const activeRole = computed(() => permissionStore.getRoleById(activeRoleId.value));
|
||||||
|
const activeRoleLabel = computed(() => activeRole.value ? `当前角色:${activeRole.value.name}` : "选择角色");
|
||||||
|
const canUpdateRole = computed(() => userStore.hasPermissions(corePermissionCodes.role.update));
|
||||||
|
const roleOptions = computed<DropdownOption[]>(() => permissionStore.roleList
|
||||||
|
.filter(item => !!item.id)
|
||||||
|
.map(item => ({
|
||||||
|
label: item.name || item.code,
|
||||||
|
value: item.id,
|
||||||
|
disabled: false
|
||||||
|
})));
|
||||||
|
const checkedPermissionIdSet = computed(() => new Set(checkedPermissionIds.value));
|
||||||
|
const childRolePermissionIdSet = computed(() => {
|
||||||
|
if (!activeRole.value) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
return collectChildRolePermissionIds(activeRole.value.childRoleIdList, new Set<string>());
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleRoleChange(value: DropdownValue): Promise<void> {
|
||||||
|
activeRoleId.value = Array.isArray(value) ? String(value[0] || "") : String(value);
|
||||||
|
await loadActiveRolePermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveRolePermissions(): Promise<void> {
|
||||||
|
const roleId = activeRoleId.value;
|
||||||
|
if (!roleId) {
|
||||||
|
checkedPermissionIds.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSeq = permissionSyncSeq + 1;
|
||||||
|
permissionSyncSeq = currentSeq;
|
||||||
|
const role = await permissionStore.loadRoleDetail(roleId);
|
||||||
|
if (currentSeq !== permissionSyncSeq || activeRoleId.value !== roleId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkedPermissionIds.value = [...role.permissionIdList];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePermissionList(): Promise<void> {
|
||||||
|
await permissionStore.setRolePermissionList(activeRoleId.value, checkedPermissionIds.value);
|
||||||
|
Toast({
|
||||||
|
theme: "success",
|
||||||
|
direction: "row",
|
||||||
|
message: "授权已保存"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPermissionChecked(permissionId: string): boolean {
|
||||||
|
return checkedPermissionIdSet.value.has(permissionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPermissionInherited(permissionId: string): boolean {
|
||||||
|
return showChildRolePermissions.value && !isPermissionChecked(permissionId) && childRolePermissionIdSet.value.has(permissionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPermissionEnabled(permissionId: string): boolean {
|
||||||
|
return isPermissionChecked(permissionId) || isPermissionInherited(permissionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPermissionChecked(permissionId: string, value: boolean): void {
|
||||||
|
if (!canUpdateRole.value || isPermissionInherited(permissionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && !checkedPermissionIds.value.includes(permissionId)) {
|
||||||
|
checkedPermissionIds.value.push(permissionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
checkedPermissionIds.value = checkedPermissionIds.value.filter(item => item !== permissionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectChildRolePermissionIds(roleIdList: string[], visitedRoleIdSet: Set<string>): Set<string> {
|
||||||
|
const permissionIdSet = new Set<string>();
|
||||||
|
for (const roleId of roleIdList) {
|
||||||
|
if (visitedRoleIdSet.has(roleId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visitedRoleIdSet.add(roleId);
|
||||||
|
const role = permissionStore.getRoleById(roleId);
|
||||||
|
if (!role) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const permissionId of role.permissionIdList) {
|
||||||
|
permissionIdSet.add(permissionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const permissionId of collectChildRolePermissionIds(role.childRoleIdList, visitedRoleIdSet)) {
|
||||||
|
permissionIdSet.add(permissionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return permissionIdSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await permissionStore.loadAll();
|
||||||
|
if (!activeRoleId.value) {
|
||||||
|
activeRoleId.value = permissionStore.roleList[0]?.id || "";
|
||||||
|
}
|
||||||
|
await loadActiveRolePermissions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.role-permission-assign {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
padding: calc(var(--app-nav-height) + 4.5rem) 0 6rem 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
top: var(--app-nav-height);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
box-shadow: var(--td-shadow-1);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
|
||||||
|
.role-selector::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherit {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #334155;
|
||||||
|
font-size: .875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
--td-switch-checked-color: #14b8a6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-cell__title) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-cell__title-text) {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-cell__description) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-cell__right) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherited {
|
||||||
|
:deep(.t-cell__title) {
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-switch--checked.t-switch--disabled) {
|
||||||
|
background: #14b8a6;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.role-permission-assign {
|
||||||
|
.role-menu {
|
||||||
|
background: #0f172a;
|
||||||
|
|
||||||
|
.inherit {
|
||||||
|
.label {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
--td-switch-checked-color: #0f766e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherited {
|
||||||
|
:deep(.t-cell__title) {
|
||||||
|
color: #5eead4;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-switch--checked.t-switch--disabled) {
|
||||||
|
background: #0f766e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="gaoPermissionCodes.user.read" class="user-edit">
|
||||||
|
<t-cell-group v-if="currentUser" theme="card">
|
||||||
|
<t-cell
|
||||||
|
:title="currentUser.name"
|
||||||
|
:description="currentUser.role"
|
||||||
|
>
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-image
|
||||||
|
class="avatar"
|
||||||
|
:src="currentUser.avatar || buildFallbackAvatar(currentUser.name)"
|
||||||
|
:alt="currentUser.name"
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
|
||||||
|
<t-cell-group v-if="currentUser" theme="card">
|
||||||
|
<t-cell title="手机号" :description="currentUser.mobile || '未填写'" />
|
||||||
|
<t-cell title="上次登录时间" :description="currentUser.lastLoginAt || '暂无记录'" />
|
||||||
|
</t-cell-group>
|
||||||
|
|
||||||
|
<t-cell-group v-if="currentUser" theme="card">
|
||||||
|
<t-cell
|
||||||
|
v-can="gaoPermissionCodes.user.update"
|
||||||
|
title="配置角色"
|
||||||
|
note="为用户授权业务角色"
|
||||||
|
arrow
|
||||||
|
hover
|
||||||
|
@click="goRoleAssign"
|
||||||
|
>
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-icon name="usergroup" size="1.75rem" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
|
||||||
|
<section v-if="currentUser" class="registration-actions bg-white">
|
||||||
|
<header>
|
||||||
|
<h4 class="title">可登记事件</h4>
|
||||||
|
</header>
|
||||||
|
<div class="actions">
|
||||||
|
<t-tag
|
||||||
|
v-for="item in scopeList"
|
||||||
|
:key="item"
|
||||||
|
variant="light-outline"
|
||||||
|
theme="success"
|
||||||
|
:content="item"
|
||||||
|
/>
|
||||||
|
<div v-if="!scopeList.length" class="empty-card">未配置</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<t-button v-can="gaoPermissionCodes.user.update" class="edit" theme="primary" @click="goEdit">编辑用户</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { UserAPI, type User } from "timi-web";
|
||||||
|
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||||
|
import GaoUserAPI from "@/api/GaoUserAPI";
|
||||||
|
import type { GaoUser } from "@/types/GaoUser";
|
||||||
|
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||||
|
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||||
|
import { buildEffectiveRoleCodeSet, getGaoRoleName } from "@/utils/gaoRole";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "UserDetail"
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const permissionStore = usePermissionSettingsStore();
|
||||||
|
const gaoUser = ref<GaoUser>();
|
||||||
|
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||||
|
|
||||||
|
const currentUser = computed(() => {
|
||||||
|
const item = gaoUser.value;
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const user = item.user;
|
||||||
|
return {
|
||||||
|
id: item.id || "",
|
||||||
|
name: user?.nick || user?.name || "未命名用户",
|
||||||
|
role: getGaoRoleName(item.roleList?.[0], permissionStore.roleList),
|
||||||
|
avatar: buildUserAvatar(user),
|
||||||
|
mobile: user?.phoneNo || "",
|
||||||
|
lastLoginAt: user?.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : ""
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const scopeList = computed(() => {
|
||||||
|
const item = gaoUser.value;
|
||||||
|
if (!item) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return registrationEventList.value
|
||||||
|
.filter(action => canRegistrationEvent(action, item))
|
||||||
|
.map(item => item.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function goEdit(): Promise<void> {
|
||||||
|
if (!currentUser.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push(`/management/users/${currentUser.value.id}/edit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goRoleAssign(): Promise<void> {
|
||||||
|
if (!currentUser.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push({
|
||||||
|
path: `/management/users/${currentUser.value.id}/roles`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackAvatar(name: string): string {
|
||||||
|
const text = encodeURIComponent((name.trim() || "用户").slice(0, 2));
|
||||||
|
return [
|
||||||
|
"data:image/svg+xml;charset=UTF-8,",
|
||||||
|
"%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E",
|
||||||
|
"%3Crect width='120' height='120' rx='24' fill='%232563eb'/%3E",
|
||||||
|
"%3Ctext x='50%25' y='54%25' fill='white' font-size='36' font-family='Arial,sans-serif' text-anchor='middle' dominant-baseline='middle'%3E",
|
||||||
|
text,
|
||||||
|
"%3C/text%3E%3C/svg%3E"
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserAvatar(user?: Partial<User>): string {
|
||||||
|
const name = user?.nick || user?.name || "用户";
|
||||||
|
return isUser(user) ? UserAPI.getAvatarURL(user) || buildFallbackAvatar(name) : buildFallbackAvatar(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUser(user?: Partial<User>): user is User {
|
||||||
|
return typeof user?.name === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRegistrationEvent(action: RegistrationEvent, item: GaoUser): boolean {
|
||||||
|
const userRoleCodeSet = buildEffectiveRoleCodeSet(item, permissionStore.roleList);
|
||||||
|
const roleCodeList = action.roleCodeList || [];
|
||||||
|
if (!roleCodeList.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return roleCodeList.some(code => userRoleCodeSet.has(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData(): Promise<void> {
|
||||||
|
const routeId = String(route.params.id || "");
|
||||||
|
const [detail, actions] = await Promise.all([
|
||||||
|
GaoUserAPI.detail(routeId),
|
||||||
|
RegistrationEventAPI.enabledList(),
|
||||||
|
permissionStore.loadAll()
|
||||||
|
]);
|
||||||
|
gaoUser.value = detail;
|
||||||
|
registrationEventList.value = actions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
direction: "row",
|
||||||
|
message: "用户详情加载失败"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.user-edit {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
border-radius: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-actions {
|
||||||
|
gap: .5rem;
|
||||||
|
margin: 0 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
border-radius: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.empty-card {
|
||||||
|
color: #52607a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.user-edit {
|
||||||
|
.panel,
|
||||||
|
.bottom-bar {
|
||||||
|
background: rgba(15, 23, 42, .92);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
.info-list li span,
|
||||||
|
.empty-card {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(15, 23, 42, .72);
|
||||||
|
border-color: rgba(148, 163, 184, .16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="savePermissionCode" class="user-edit">
|
||||||
|
<t-form class="form" label-align="left">
|
||||||
|
<t-form-item label="姓名">
|
||||||
|
<t-input v-model.trim="formModel.name" placeholder="姓名" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item v-if="isCreateMode" label="手机号">
|
||||||
|
<t-input v-model.trim="formModel.mobile" placeholder="手机号" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
<t-form-item v-if="isCreateMode" label="密码">
|
||||||
|
<t-input v-model.trim="formModel.password" type="password" placeholder="密码" borderless />
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
<t-button class="save" theme="primary" :loading="isSaving" @click="saveUser">保存</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import GaoUserAPI from "@/api/GaoUserAPI";
|
||||||
|
import type { GaoUser } from "@/types/GaoUser";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "UserEdit"
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const currentGaoUser = ref<GaoUser>();
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const formModel = reactive({
|
||||||
|
id: "",
|
||||||
|
userId: "",
|
||||||
|
name: "",
|
||||||
|
mobile: "",
|
||||||
|
password: ""
|
||||||
|
});
|
||||||
|
const isCreateMode = computed(() => !String(route.params.id || ""));
|
||||||
|
const savePermissionCode = computed(() => isCreateMode.value ? gaoPermissionCodes.user.create : gaoPermissionCodes.user.update);
|
||||||
|
|
||||||
|
async function fillForm(): Promise<void> {
|
||||||
|
const routeId = String(route.params.id || "");
|
||||||
|
if (routeId) {
|
||||||
|
const current = await GaoUserAPI.detail(routeId);
|
||||||
|
currentGaoUser.value = current;
|
||||||
|
Object.assign(formModel, {
|
||||||
|
id: current.id,
|
||||||
|
userId: current.userId || "",
|
||||||
|
name: current.user?.nick || current.user?.name || "",
|
||||||
|
mobile: current.user?.phoneNo || "",
|
||||||
|
password: ""
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(formModel, {
|
||||||
|
id: "",
|
||||||
|
userId: "",
|
||||||
|
name: "",
|
||||||
|
mobile: "",
|
||||||
|
password: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser(): Promise<void> {
|
||||||
|
if (!formModel.name) {
|
||||||
|
Toast({
|
||||||
|
theme: "warning",
|
||||||
|
direction: "row",
|
||||||
|
message: "请填写姓名"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCreateMode.value && !formModel.password) {
|
||||||
|
Toast({
|
||||||
|
theme: "warning",
|
||||||
|
direction: "row",
|
||||||
|
message: "请填写密码"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
const payload: GaoUser = {
|
||||||
|
id: formModel.id || undefined,
|
||||||
|
userId: formModel.userId || undefined,
|
||||||
|
enabled: currentGaoUser.value?.enabled ?? true,
|
||||||
|
remark: currentGaoUser.value?.remark || "",
|
||||||
|
user: {
|
||||||
|
id: formModel.userId || undefined,
|
||||||
|
name: formModel.name,
|
||||||
|
nick: formModel.name,
|
||||||
|
phoneNo: formModel.mobile || undefined,
|
||||||
|
password: isCreateMode.value ? formModel.password : undefined
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const saved = isCreateMode.value ? await GaoUserAPI.create(payload) : await GaoUserAPI.update(payload);
|
||||||
|
Toast({
|
||||||
|
theme: "success",
|
||||||
|
direction: "row",
|
||||||
|
message: "用户已保存"
|
||||||
|
});
|
||||||
|
await router.push(`/management/users/${saved.id}`);
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await fillForm();
|
||||||
|
} catch {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
direction: "row",
|
||||||
|
message: "用户数据加载失败"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.user-edit {
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||||
|
|
||||||
|
.form {
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.user-edit {
|
||||||
|
:deep(.form-item .t-form__label) {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="gaoPermissionCodes.user.read" class="user-list">
|
||||||
|
<div v-for="item in userList" :key="item.id" class="item" @click="goDetail(item)">
|
||||||
|
<t-image :src="buildUserAvatar(item)" :alt="getUserName(item)" fit="cover" class="avatar" />
|
||||||
|
<div class="content">
|
||||||
|
<div class="header">
|
||||||
|
<span class="name" v-text="getUserName(item)" />
|
||||||
|
<t-tag class="meta" size="small" theme="success" variant="outline" :content="getRoleName(item)" />
|
||||||
|
</div>
|
||||||
|
<span class="events gray" v-text="`可登记事件:${getActionScope(item)}`" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<t-button v-can="gaoPermissionCodes.user.create" class="add" theme="primary" type="button" @click="goCreate">
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="add" />
|
||||||
|
</template>
|
||||||
|
新增
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { UserAPI, type User } from "timi-web";
|
||||||
|
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||||
|
import GaoUserAPI from "@/api/GaoUserAPI";
|
||||||
|
import type { GaoUser } from "@/types/GaoUser";
|
||||||
|
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||||
|
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||||
|
import { buildEffectiveRoleCodeSet, getGaoRoleName } from "@/utils/gaoRole";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "UserList"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const permissionStore = usePermissionSettingsStore();
|
||||||
|
const userList = ref<GaoUser[]>([]);
|
||||||
|
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||||
|
|
||||||
|
function getUserName(item: GaoUser): string {
|
||||||
|
return item.user?.nick || item.user?.name || "未命名用户";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleName(item: GaoUser): string {
|
||||||
|
return getGaoRoleName(item.roleList?.[0], permissionStore.roleList);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionScope(item: GaoUser): string {
|
||||||
|
const nameList = registrationEventList.value
|
||||||
|
.filter(action => canRegistrationEvent(action, item))
|
||||||
|
.map(action => action.name);
|
||||||
|
return nameList.join("、") || "未配置";
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRegistrationEvent(action: RegistrationEvent, item: GaoUser): boolean {
|
||||||
|
const userRoleCodeSet = buildEffectiveRoleCodeSet(item, permissionStore.roleList);
|
||||||
|
const roleCodeList = action.roleCodeList || [];
|
||||||
|
if (!roleCodeList.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return roleCodeList.some(code => userRoleCodeSet.has(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserAvatar(item: GaoUser): string {
|
||||||
|
return isUser(item.user) ? UserAPI.getAvatarURL(item.user) || buildFallbackAvatar(getUserName(item)) : buildFallbackAvatar(getUserName(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUser(user?: Partial<User>): user is User {
|
||||||
|
return typeof user?.name === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackAvatar(name: string): string {
|
||||||
|
const text = encodeURIComponent((name.trim() || "用户").slice(0, 2));
|
||||||
|
return [
|
||||||
|
"data:image/svg+xml;charset=UTF-8,",
|
||||||
|
"%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E",
|
||||||
|
"%3Crect width='120' height='120' rx='24' fill='%232563eb'/%3E",
|
||||||
|
"%3Ctext x='50%25' y='54%25' fill='white' font-size='36' font-family='Arial,sans-serif' text-anchor='middle' dominant-baseline='middle'%3E",
|
||||||
|
text,
|
||||||
|
"%3C/text%3E%3C/svg%3E"
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData(): Promise<void> {
|
||||||
|
const [userResult, actions] = await Promise.all([
|
||||||
|
GaoUserAPI.list({
|
||||||
|
index: 0,
|
||||||
|
size: 100
|
||||||
|
}),
|
||||||
|
RegistrationEventAPI.enabledList(),
|
||||||
|
permissionStore.loadAll()
|
||||||
|
]);
|
||||||
|
userList.value = userResult.list || [];
|
||||||
|
registrationEventList.value = actions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goCreate(): Promise<void> {
|
||||||
|
await router.push("/management/users/create");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goDetail(item: GaoUser): Promise<void> {
|
||||||
|
if (!item.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push(`/management/users/${item.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
direction: "row",
|
||||||
|
message: "用户列表加载失败"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.user-list {
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: .75rem;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border: 1px solid var(--td-border-level-1-color);
|
||||||
|
background: #f7faff;
|
||||||
|
border-radius: 1rem;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
flex: none;
|
||||||
|
border-radius: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events {
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.user-list {
|
||||||
|
.item {
|
||||||
|
background: rgba(30, 41, 59, .9);
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<template>
|
||||||
|
<div v-can="corePermissionCodes.userRole.read" class="user-role-assign">
|
||||||
|
<div class="user-menu">
|
||||||
|
<t-dropdown-menu>
|
||||||
|
<t-dropdown-item
|
||||||
|
:label="activeUserLabel"
|
||||||
|
:options="userOptions"
|
||||||
|
:value="activeGaoUserId"
|
||||||
|
@change="handleUserChange"
|
||||||
|
/>
|
||||||
|
</t-dropdown-menu>
|
||||||
|
</div>
|
||||||
|
<t-cell-group v-if="activeUser && roleList.length" theme="card">
|
||||||
|
<t-cell
|
||||||
|
v-for="item in roleList"
|
||||||
|
:key="item.id"
|
||||||
|
:title="item.name"
|
||||||
|
:description="item.code"
|
||||||
|
>
|
||||||
|
<template #right-icon>
|
||||||
|
<t-switch
|
||||||
|
v-can="userRoleWriteAccess"
|
||||||
|
:model-value="isRoleChecked(item.id)"
|
||||||
|
@change="value => setRoleChecked(item.id, value === true)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<t-empty v-else-if="!loading" description="暂无可授权角色" />
|
||||||
|
<t-button
|
||||||
|
v-can="userRoleWriteAccess"
|
||||||
|
class="save"
|
||||||
|
theme="primary"
|
||||||
|
:loading="saving"
|
||||||
|
@click="saveRoleList"
|
||||||
|
>
|
||||||
|
保存授权
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownOption, DropdownValue } from "tdesign-mobile-vue";
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { OrderType, RoleAPI, type ModuleCode, type Role } from "timi-web";
|
||||||
|
import GaoUserAPI from "@/api/GaoUserAPI";
|
||||||
|
import type { GaoUser } from "@/types/GaoUser";
|
||||||
|
import { corePermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "UserRoleAssign"
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const gaoModuleCode: ModuleCode = "GAO";
|
||||||
|
const pageSize = 1000;
|
||||||
|
const userList = ref<GaoUser[]>([]);
|
||||||
|
const roleList = ref<Role[]>([]);
|
||||||
|
const routeGaoUserId = computed(() => String(route.params.id || route.query.gaoUserId || ""));
|
||||||
|
const activeGaoUserId = ref(routeGaoUserId.value);
|
||||||
|
const checkedRoleIds = ref<string[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
let roleSyncSeq = 0;
|
||||||
|
|
||||||
|
const userRoleWriteAccess = {
|
||||||
|
value: [
|
||||||
|
corePermissionCodes.userRole.create,
|
||||||
|
corePermissionCodes.userRole.delete
|
||||||
|
],
|
||||||
|
mode: "and"
|
||||||
|
} as const;
|
||||||
|
const activeUser = computed(() => userList.value.find(item => item.id === activeGaoUserId.value));
|
||||||
|
const activeUserLabel = computed(() => activeUser.value ? `当前用户:${getUserName(activeUser.value)}` : "选择用户");
|
||||||
|
const userOptions = computed<DropdownOption[]>(() => userList.value
|
||||||
|
.filter(item => !!item.id)
|
||||||
|
.map(item => ({
|
||||||
|
label: getUserName(item),
|
||||||
|
value: item.id as string,
|
||||||
|
disabled: false
|
||||||
|
})));
|
||||||
|
|
||||||
|
async function handleUserChange(value: DropdownValue): Promise<void> {
|
||||||
|
activeGaoUserId.value = Array.isArray(value) ? String(value[0] || "") : String(value);
|
||||||
|
await loadActiveUserRoles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const [userResult, roles] = await Promise.all([
|
||||||
|
GaoUserAPI.list({
|
||||||
|
index: 0,
|
||||||
|
size: 100
|
||||||
|
}),
|
||||||
|
loadGaoRoleList()
|
||||||
|
]);
|
||||||
|
userList.value = userResult.list || [];
|
||||||
|
roleList.value = roles || [];
|
||||||
|
if (!activeGaoUserId.value) {
|
||||||
|
activeGaoUserId.value = userList.value[0]?.id || "";
|
||||||
|
}
|
||||||
|
await loadActiveUserRoles();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveUserRoles(): Promise<void> {
|
||||||
|
const gaoUserId = activeGaoUserId.value;
|
||||||
|
const gaoUser = activeUser.value;
|
||||||
|
if (!gaoUserId || !gaoUser?.userId) {
|
||||||
|
checkedRoleIds.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSeq = roleSyncSeq + 1;
|
||||||
|
roleSyncSeq = currentSeq;
|
||||||
|
const roles = await RoleAPI.authorizedList(gaoUser.userId, gaoModuleCode);
|
||||||
|
if (currentSeq !== roleSyncSeq || activeGaoUserId.value !== gaoUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkedRoleIds.value = roles.map(getRoleId).filter((item): item is string => !!item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRoleList(): Promise<void> {
|
||||||
|
const current = activeUser.value;
|
||||||
|
if (!current?.id || !current.userId) {
|
||||||
|
Toast({
|
||||||
|
theme: "warning",
|
||||||
|
direction: "row",
|
||||||
|
message: "请选择用户"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await RoleAPI.authorize({
|
||||||
|
userId: current.userId,
|
||||||
|
moduleCode: gaoModuleCode,
|
||||||
|
roleIdList: [...checkedRoleIds.value]
|
||||||
|
});
|
||||||
|
Toast({
|
||||||
|
theme: "success",
|
||||||
|
direction: "row",
|
||||||
|
message: "用户角色已保存"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserName(item: GaoUser): string {
|
||||||
|
return item.user?.nick || item.user?.name || item.user?.phoneNo || "未命名用户";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGaoRoleList(): Promise<Role[]> {
|
||||||
|
const result = await RoleAPI.list({
|
||||||
|
index: 0,
|
||||||
|
size: pageSize,
|
||||||
|
equalsExample: {
|
||||||
|
moduleCode: gaoModuleCode
|
||||||
|
},
|
||||||
|
orderMap: {
|
||||||
|
code: OrderType.ASC
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRoleChecked(roleId: string): boolean {
|
||||||
|
return checkedRoleIds.value.includes(roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleId(role: Role | string): string {
|
||||||
|
if (typeof role !== "string") {
|
||||||
|
return role.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullRole = roleList.value.find(item => item.code === role || `${item.moduleCode}:${item.code}` === role);
|
||||||
|
return fullRole?.id || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRoleChecked(roleId: string, value: boolean): void {
|
||||||
|
if (value && !checkedRoleIds.value.includes(roleId)) {
|
||||||
|
checkedRoleIds.value.push(roleId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
checkedRoleIds.value = checkedRoleIds.value.filter(item => item !== roleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
direction: "row",
|
||||||
|
message: "用户角色加载失败"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.user-role-assign {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
padding: calc(var(--app-nav-height) + 4.5rem) 0 6rem 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.user-menu {
|
||||||
|
top: var(--app-nav-height);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
position: fixed;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.user-role-assign {
|
||||||
|
.user-menu {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<div class="entry-page">
|
||||||
|
<section class="entry-list">
|
||||||
|
<div v-for="item in visibleEntryList" :key="item.path" class="item" @click="go(item.path)">
|
||||||
|
<div class="body">
|
||||||
|
<t-icon :name="item.icon" size="1.75rem" />
|
||||||
|
<h2 v-text="item.title" />
|
||||||
|
</div>
|
||||||
|
<p v-text="item.desc" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useUserStore } from "timi-web";
|
||||||
|
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "CustomerTab"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
const entryList = [
|
||||||
|
{
|
||||||
|
path: "/customer/quick-entry",
|
||||||
|
permission: gaoPermissionCodes.registrationEventRecord.create,
|
||||||
|
icon: "scan",
|
||||||
|
title: "快速补录登记",
|
||||||
|
desc: "对以往未绑定二维码的客户进行快速补录和登记"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/customer/quick-sign-in",
|
||||||
|
permission: gaoPermissionCodes.registrationEventRecord.create,
|
||||||
|
icon: "scan",
|
||||||
|
title: "快速扫码登记",
|
||||||
|
desc: "扫码选择客户并按配置事件登记"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/customers/create?redirect=/customers",
|
||||||
|
permission: gaoPermissionCodes.customer.create,
|
||||||
|
icon: "edit-1",
|
||||||
|
title: "录入",
|
||||||
|
desc: "新建客户档案并完善基础资料"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/customer/registration",
|
||||||
|
permission: gaoPermissionCodes.registrationEventRecord.create,
|
||||||
|
icon: "task-checked",
|
||||||
|
title: "登记",
|
||||||
|
desc: "按流程完成客户登记"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const visibleEntryList = computed(() => entryList.filter(item => userStore.hasPermissions(item.permission)));
|
||||||
|
|
||||||
|
async function go(path: string): Promise<void> {
|
||||||
|
await router.push(path);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.entry-page {
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 1rem 1rem 1rem;
|
||||||
|
|
||||||
|
.entry-list {
|
||||||
|
display: grid;
|
||||||
|
gap: .875rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
color: #0f766e;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
|
||||||
|
align-items: center;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.body {
|
||||||
|
inset: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
position: absolute;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
color: #7b889d;
|
||||||
|
margin: 0;
|
||||||
|
position: absolute;
|
||||||
|
font-size: .75rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.entry-page {
|
||||||
|
.item {
|
||||||
|
background: rgba(15, 23, 42, .88);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<template>
|
||||||
|
<div class="management-tab">
|
||||||
|
<t-cell-group class="user-card" theme="card">
|
||||||
|
<t-cell
|
||||||
|
:title="currentUserName"
|
||||||
|
:description="currentUserDesc"
|
||||||
|
arrow
|
||||||
|
hover
|
||||||
|
@click="go('/management/profile')"
|
||||||
|
>
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-image
|
||||||
|
class="avatar"
|
||||||
|
:src="currentUserAvatar"
|
||||||
|
:alt="currentUserName"
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group v-for="group in visibleMenuGroupList" :key="group.title" :title="group.title" theme="card">
|
||||||
|
<t-cell
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.path"
|
||||||
|
class="item"
|
||||||
|
:title="item.title"
|
||||||
|
arrow
|
||||||
|
hover
|
||||||
|
@click="go(item.path)"
|
||||||
|
>
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-icon :name="item.icon" size="1.75rem" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-cell-group>
|
||||||
|
<footer class="footer">
|
||||||
|
<p>版本 1.0.0</p>
|
||||||
|
<p>技术支持:夜雨</p>
|
||||||
|
<p>Copyright © 2026 imyeyu.com</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { UserAPI, useUserStore } from "timi-web";
|
||||||
|
import { corePermissionCodes, coreRoleCodes, gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||||
|
|
||||||
|
type UserAvatarSource = Parameters<typeof UserAPI.getAvatarURL>[0];
|
||||||
|
|
||||||
|
type MenuItem = {
|
||||||
|
icon: string;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MenuGroup = {
|
||||||
|
items: MenuItem[];
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ManagementTab"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
const currentUser = computed(() => userStore.loginUser.user);
|
||||||
|
|
||||||
|
const currentUserName = computed(() => currentUser.value?.nick || currentUser.value?.name || "未登录用户");
|
||||||
|
|
||||||
|
const currentUserDesc = computed(() => currentUser.value?.phoneNo || currentUser.value?.email || "暂无联系方式");
|
||||||
|
|
||||||
|
const currentUserAvatar = computed(() => buildUserAvatar(currentUser.value));
|
||||||
|
const canManagePermission = computed(() => (
|
||||||
|
userStore.hasRoles(coreRoleCodes.admin) || userStore.hasRoles(coreRoleCodes.userRoleAdmin)
|
||||||
|
) && userStore.hasPermissions([
|
||||||
|
corePermissionCodes.role.read,
|
||||||
|
corePermissionCodes.permission.read
|
||||||
|
], "and"));
|
||||||
|
const visibleMenuGroupList = computed<MenuGroup[]>(() => menuGroupList.value
|
||||||
|
.map(group => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter(item => item.visible)
|
||||||
|
}))
|
||||||
|
.filter(group => group.items.length));
|
||||||
|
|
||||||
|
const menuGroupList = computed<MenuGroup[]>(() => [
|
||||||
|
{
|
||||||
|
title: "设置",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: "setting",
|
||||||
|
path: "/management/settings",
|
||||||
|
title: "应用设置",
|
||||||
|
visible: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "secured",
|
||||||
|
path: "/management/permissions",
|
||||||
|
title: "权限设置",
|
||||||
|
visible: canManagePermission.value
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "用户与客户",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: "usergroup",
|
||||||
|
path: "/management/customers",
|
||||||
|
title: "客户管理",
|
||||||
|
visible: userStore.hasPermissions(gaoPermissionCodes.customer.read)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "root-list",
|
||||||
|
path: "/management/users",
|
||||||
|
title: "用户管理",
|
||||||
|
visible: userStore.hasPermissions(gaoPermissionCodes.user.read)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "qrcode",
|
||||||
|
path: "/management/qr-code",
|
||||||
|
title: "生成客户二维码",
|
||||||
|
visible: userStore.hasPermissions(gaoPermissionCodes.qrCode.create)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "登记管理",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: "task",
|
||||||
|
path: "/management/registration-events",
|
||||||
|
title: "登记事件",
|
||||||
|
visible: userStore.hasPermissions([gaoPermissionCodes.registrationEvent.read, gaoPermissionCodes.user.read], "and")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "history",
|
||||||
|
path: "/management/registration-event-records",
|
||||||
|
title: "登记事件记录",
|
||||||
|
visible: userStore.hasPermissions(gaoPermissionCodes.registrationEventRecord.read)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "图表",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: "usergroup",
|
||||||
|
path: "/management/customer-data-charts",
|
||||||
|
title: "客户数据",
|
||||||
|
visible: userStore.hasPermissions(gaoPermissionCodes.stat.read)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "chart",
|
||||||
|
path: "/management/data-charts",
|
||||||
|
title: "全局数据",
|
||||||
|
visible: userStore.hasPermissions(gaoPermissionCodes.stat.read)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
function buildFallbackAvatar(name: string): string {
|
||||||
|
const text = encodeURIComponent((name.trim() || "用户").slice(0, 2));
|
||||||
|
return [
|
||||||
|
"data:image/svg+xml;charset=UTF-8,",
|
||||||
|
"%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E",
|
||||||
|
"%3Crect width='120' height='120' rx='24' fill='%230f766e'/%3E",
|
||||||
|
"%3Ctext x='50%25' y='54%25' fill='white' font-size='36' font-family='Arial,sans-serif' text-anchor='middle' dominant-baseline='middle'%3E",
|
||||||
|
text,
|
||||||
|
"%3C/text%3E%3C/svg%3E"
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserAvatar(user?: UserAvatarSource): string {
|
||||||
|
return UserAPI.getAvatarURL(user) || buildFallbackAvatar(currentUserName.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function go(path: string): Promise<void> {
|
||||||
|
await router.push(path);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.management-tab {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
padding: calc(var(--app-nav-height) + 1rem) 0 5rem 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 1.25rem 1rem calc(1rem + env(safe-area-inset-bottom, 0));
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #52607a;
|
||||||
|
font-size: .8125rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.management-tab {
|
||||||
|
.office {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import RootLayout from "@/layout/RootLayout.vue";
|
||||||
|
import MainLayout from "@/layout/MainLayout.vue";
|
||||||
|
import Login from "@/pages/Login.vue";
|
||||||
|
import chartRoutes from "@/router/routes/chartRoutes";
|
||||||
|
import customerRoutes from "@/router/routes/customerRoutes";
|
||||||
|
import { managementAccountRoutes, managementSettingRoutes } from "@/router/routes/managementRoutes";
|
||||||
|
import permissionRoutes from "@/router/routes/permissionRoutes";
|
||||||
|
import registrationRoutes from "@/router/routes/registrationRoutes";
|
||||||
|
import tabRoutes from "@/router/routes/tabRoutes";
|
||||||
|
import userRoutes from "@/router/routes/userRoutes";
|
||||||
|
import { useGlobalUIStore } from "@/store/globalUI";
|
||||||
|
import { useUserStore } from "timi-web";
|
||||||
|
|
||||||
|
const detailRoutes = [
|
||||||
|
...customerRoutes,
|
||||||
|
...userRoutes,
|
||||||
|
...registrationRoutes,
|
||||||
|
...managementSettingRoutes,
|
||||||
|
...permissionRoutes,
|
||||||
|
...chartRoutes,
|
||||||
|
...managementAccountRoutes
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory("/"),
|
||||||
|
scrollBehavior(_to, _from, savedPosition) {
|
||||||
|
if (savedPosition) {
|
||||||
|
return savedPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: 0,
|
||||||
|
top: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
component: RootLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
redirect: "/customers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "login",
|
||||||
|
name: "Login",
|
||||||
|
component: Login,
|
||||||
|
meta: {
|
||||||
|
depth: 1,
|
||||||
|
navBarVisible: false,
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
component: MainLayout,
|
||||||
|
meta: {
|
||||||
|
depth: 1,
|
||||||
|
navBarVisible: false,
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
...tabRoutes,
|
||||||
|
...detailRoutes
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/:pathMatch(.*)*",
|
||||||
|
redirect: "/customers"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
if (to.path === "/login") {
|
||||||
|
return userStore.isLogged() ? "/management" : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userStore.isLogged()) {
|
||||||
|
return {
|
||||||
|
path: "/login",
|
||||||
|
query: {
|
||||||
|
redirect: to.fullPath
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
const globalUIStore = useGlobalUIStore();
|
||||||
|
const isDarkMode = globalUIStore.themeMode === "dark";
|
||||||
|
const fallbackBackground = isDarkMode ? "#10161f" : "#F5F5F8";
|
||||||
|
const targetBackground = isDarkMode ? to.meta.darkBodyBackground : to.meta.bodyBackground;
|
||||||
|
globalUIStore.setBodyBackground(typeof targetBackground === "string" ? targetBackground : fallbackBackground);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
|
||||||
|
const chartRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/management/data-charts",
|
||||||
|
name: "ManagementDataCharts",
|
||||||
|
component: () => import("@/pages/management/ManagementDataCharts.vue"),
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "全局数据图表",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/customer-data-charts",
|
||||||
|
name: "CustomerDataCharts",
|
||||||
|
component: () => import("@/pages/management/CustomerDataCharts.vue"),
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "客户数据图表",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/customers/:id/data-charts",
|
||||||
|
name: "CustomerDataChartsFromCustomer",
|
||||||
|
component: () => import("@/pages/management/CustomerDataCharts.vue"),
|
||||||
|
meta: {
|
||||||
|
depth: 5,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "客户数据图表",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default chartRoutes;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import QuickEntryPage from "@/pages/customer/QuickEntryPage.vue";
|
||||||
|
import QuickSignInPage from "@/pages/customer/QuickSignInPage.vue";
|
||||||
|
import RegistrationEventRecordEdit from "@/pages/management/RegistrationEventRecordEdit.vue";
|
||||||
|
import CustomerList from "@/pages/management/CustomerList.vue";
|
||||||
|
import CustomerDetail from "@/pages/management/CustomerDetail.vue";
|
||||||
|
import CustomerEdit from "@/pages/management/CustomerEdit.vue";
|
||||||
|
|
||||||
|
const customerRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/customer/quick-entry",
|
||||||
|
name: "QuickEntryPage",
|
||||||
|
component: QuickEntryPage,
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "快速补录登记",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/customer/quick-sign-in",
|
||||||
|
name: "QuickSignInPage",
|
||||||
|
component: QuickSignInPage,
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "快速扫码登记",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/customer/registration",
|
||||||
|
name: "CustomerRegistrationEventRecordCreate",
|
||||||
|
component: RegistrationEventRecordEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "登记",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/customers",
|
||||||
|
name: "CustomerList",
|
||||||
|
component: CustomerList,
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "客户管理",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/customers/create",
|
||||||
|
name: "CustomerCreate",
|
||||||
|
component: CustomerEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "新增客户",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/customers/:id",
|
||||||
|
name: "CustomerDetail",
|
||||||
|
component: CustomerDetail,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "客户详情",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/customers/:id/edit",
|
||||||
|
name: "CustomerEdit",
|
||||||
|
component: CustomerEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 5,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "编辑客户",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default customerRoutes;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import ManagementSettings from "@/pages/management/ManagementSettings.vue";
|
||||||
|
import ManagementProfile from "@/pages/management/ManagementProfile.vue";
|
||||||
|
import ManagementPassword from "@/pages/management/ManagementPassword.vue";
|
||||||
|
import ManagementQrCode from "@/pages/management/ManagementQrCode.vue";
|
||||||
|
|
||||||
|
export const managementSettingRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/management/settings",
|
||||||
|
name: "ManagementSettings",
|
||||||
|
component: ManagementSettings,
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "应用设置",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const managementAccountRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/management/profile",
|
||||||
|
name: "ManagementProfile",
|
||||||
|
component: ManagementProfile,
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "当前用户",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/profile/password",
|
||||||
|
name: "ManagementPassword",
|
||||||
|
component: ManagementPassword,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "修改密码",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/qr-code",
|
||||||
|
name: "ManagementQrCode",
|
||||||
|
component: ManagementQrCode,
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "生成二维码",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const managementRoutes: RouteRecordRaw[] = [
|
||||||
|
...managementSettingRoutes,
|
||||||
|
...managementAccountRoutes
|
||||||
|
];
|
||||||
|
|
||||||
|
export default managementRoutes;
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import PermissionSettings from "@/pages/management/PermissionSettings.vue";
|
||||||
|
import RoleList from "@/pages/management/RoleList.vue";
|
||||||
|
import RoleEdit from "@/pages/management/RoleEdit.vue";
|
||||||
|
import PermissionList from "@/pages/management/PermissionList.vue";
|
||||||
|
import PermissionEdit from "@/pages/management/PermissionEdit.vue";
|
||||||
|
import RolePermissionAssign from "@/pages/management/RolePermissionAssign.vue";
|
||||||
|
import UserRoleAssign from "@/pages/management/UserRoleAssign.vue";
|
||||||
|
|
||||||
|
const permissionRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/management/permissions",
|
||||||
|
name: "PermissionSettings",
|
||||||
|
component: PermissionSettings,
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "权限设置",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/permissions/roles",
|
||||||
|
name: "RoleList",
|
||||||
|
component: RoleList,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "角色管理",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/permissions/roles/create",
|
||||||
|
name: "RoleCreate",
|
||||||
|
component: RoleEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 5,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "新增角色",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/permissions/roles/:id",
|
||||||
|
name: "RoleEdit",
|
||||||
|
component: RoleEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 5,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "编辑角色",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/permissions/items",
|
||||||
|
name: "PermissionList",
|
||||||
|
component: PermissionList,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "权限管理",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/permissions/items/create",
|
||||||
|
name: "PermissionCreate",
|
||||||
|
component: PermissionEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 5,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "新增权限",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/permissions/items/:id",
|
||||||
|
name: "PermissionEdit",
|
||||||
|
component: PermissionEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 5,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "编辑权限",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/permissions/assign",
|
||||||
|
name: "RolePermissionAssign",
|
||||||
|
component: RolePermissionAssign,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "角色授权权限",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/permissions/user-roles",
|
||||||
|
name: "UserRoleAssign",
|
||||||
|
component: UserRoleAssign,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "用户授权角色",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default permissionRoutes;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import RegistrationEventEdit from "@/pages/management/RegistrationEventEdit.vue";
|
||||||
|
import RegistrationEventList from "@/pages/management/RegistrationEventList.vue";
|
||||||
|
import RegistrationEventRecordDetail from "@/pages/management/RegistrationEventRecordDetail.vue";
|
||||||
|
import RegistrationEventRecordEdit from "@/pages/management/RegistrationEventRecordEdit.vue";
|
||||||
|
import RegistrationEventRecordList from "@/pages/management/RegistrationEventRecordList.vue";
|
||||||
|
|
||||||
|
const registrationRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/management/registration-events",
|
||||||
|
name: "RegistrationEventList",
|
||||||
|
component: RegistrationEventList,
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "登记事件管理",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/registration-events/create",
|
||||||
|
name: "RegistrationEventCreate",
|
||||||
|
component: RegistrationEventEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "新增登记事件",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/registration-events/:id/edit",
|
||||||
|
name: "RegistrationEventEdit",
|
||||||
|
component: RegistrationEventEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "编辑登记事件",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/registration-event-records",
|
||||||
|
name: "RegistrationEventRecordList",
|
||||||
|
component: RegistrationEventRecordList,
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "登记事件记录管理",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/registration-event-records/create",
|
||||||
|
name: "RegistrationEventRecordCreate",
|
||||||
|
component: RegistrationEventRecordEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "新增登记事件记录",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/registration-event-records/:id",
|
||||||
|
name: "RegistrationEventRecordDetail",
|
||||||
|
component: RegistrationEventRecordDetail,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "登记事件记录详情",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/registration-event-records/:id/edit",
|
||||||
|
name: "RegistrationEventRecordEdit",
|
||||||
|
component: RegistrationEventRecordEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 5,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "编辑登记事件记录",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default registrationRoutes;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import CustomerTab from "@/pages/tabs/CustomerTab.vue";
|
||||||
|
import ManagementTab from "@/pages/tabs/ManagementTab.vue";
|
||||||
|
|
||||||
|
const tabRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/customers",
|
||||||
|
name: "CustomerTab",
|
||||||
|
component: CustomerTab,
|
||||||
|
meta: {
|
||||||
|
depth: 2,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: false,
|
||||||
|
navBarTitle: "客户",
|
||||||
|
tabBarVisible: true,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management",
|
||||||
|
name: "ManagementTab",
|
||||||
|
component: ManagementTab,
|
||||||
|
meta: {
|
||||||
|
depth: 2,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: false,
|
||||||
|
navBarTitle: "管理",
|
||||||
|
tabBarVisible: true,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default tabRoutes;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import UserList from "@/pages/management/UserList.vue";
|
||||||
|
import UserDetail from "@/pages/management/UserDetail.vue";
|
||||||
|
import UserEdit from "@/pages/management/UserEdit.vue";
|
||||||
|
import UserRoleAssign from "@/pages/management/UserRoleAssign.vue";
|
||||||
|
|
||||||
|
const userRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/management/users",
|
||||||
|
name: "UserList",
|
||||||
|
component: UserList,
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "用户管理",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/users/create",
|
||||||
|
name: "UserCreatePage",
|
||||||
|
component: UserEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "新增用户",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/users/:id",
|
||||||
|
name: "UserDetail",
|
||||||
|
component: UserDetail,
|
||||||
|
meta: {
|
||||||
|
depth: 4,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "用户详情",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/users/:id/edit",
|
||||||
|
name: "UserEdit",
|
||||||
|
component: UserEdit,
|
||||||
|
meta: {
|
||||||
|
depth: 5,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "编辑用户",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/management/users/:id/roles",
|
||||||
|
name: "UserRoleAssignFromUser",
|
||||||
|
component: UserRoleAssign,
|
||||||
|
meta: {
|
||||||
|
depth: 5,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "配置角色",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F5F5F8",
|
||||||
|
darkBodyBackground: "#10161f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default userRoutes;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Pinia } from "pinia";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const DEFAULT_BODY_BACKGROUND = "#FFF";
|
||||||
|
const THEME_STORAGE_KEY = "gao-customer-h5:theme-mode";
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
function readStoredThemeMode(): ThemeMode {
|
||||||
|
const rawValue = localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (rawValue === "light" || rawValue === "dark" || rawValue === "system") {
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = defineStore("globalUI", () => {
|
||||||
|
const bodyBackground = ref<string>(DEFAULT_BODY_BACKGROUND);
|
||||||
|
const themeMode = ref<ThemeMode>(readStoredThemeMode());
|
||||||
|
|
||||||
|
function setBodyBackground(color?: string): void {
|
||||||
|
bodyBackground.value = color || DEFAULT_BODY_BACKGROUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setThemeMode(mode: ThemeMode): void {
|
||||||
|
themeMode.value = mode;
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bodyBackground,
|
||||||
|
themeMode,
|
||||||
|
setBodyBackground,
|
||||||
|
setThemeMode
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useGlobalUIStore(pinia?: Pinia) {
|
||||||
|
return store(pinia);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
export const useNavBarStore = defineStore("nav-bar", () => {
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const height = ref(0);
|
||||||
|
const title = ref("");
|
||||||
|
const backTo = ref<string>();
|
||||||
|
const rightIcon = ref("");
|
||||||
|
const rightText = ref("");
|
||||||
|
const rightTextColor = ref("");
|
||||||
|
const rightAction = shallowRef<(() => void | Promise<void>) | undefined>();
|
||||||
|
const customTitleVisible = ref(false);
|
||||||
|
const customRightVisible = ref(false);
|
||||||
|
|
||||||
|
const isShowing = computed(() => !!router.currentRoute.value.meta.navBarVisible);
|
||||||
|
const canBack = computed(() => !!router.currentRoute.value.meta.navBarCanBack);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.meta.navBarTitle,
|
||||||
|
(value) => {
|
||||||
|
title.value = typeof value === "string" ? value : "";
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function setHeight(value: number): void {
|
||||||
|
height.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBackTo(value?: string): void {
|
||||||
|
backTo.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRightIcon(value?: string, action?: () => void | Promise<void>): void {
|
||||||
|
rightIcon.value = value || "";
|
||||||
|
rightAction.value = action;
|
||||||
|
if (value) {
|
||||||
|
rightText.value = "";
|
||||||
|
rightTextColor.value = "";
|
||||||
|
customRightVisible.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRightText(value?: string, color?: string, action?: () => void | Promise<void>): void {
|
||||||
|
rightText.value = value || "";
|
||||||
|
rightTextColor.value = color || "";
|
||||||
|
rightAction.value = action;
|
||||||
|
if (value) {
|
||||||
|
rightIcon.value = "";
|
||||||
|
customRightVisible.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCustomTitleVisible(value: boolean): void {
|
||||||
|
customTitleVisible.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCustomRightVisible(value: boolean): void {
|
||||||
|
customRightVisible.value = value;
|
||||||
|
if (value) {
|
||||||
|
rightIcon.value = "";
|
||||||
|
rightText.value = "";
|
||||||
|
rightTextColor.value = "";
|
||||||
|
rightAction.value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRight(): void {
|
||||||
|
rightIcon.value = "";
|
||||||
|
rightText.value = "";
|
||||||
|
rightTextColor.value = "";
|
||||||
|
rightAction.value = undefined;
|
||||||
|
customRightVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
height,
|
||||||
|
title,
|
||||||
|
backTo,
|
||||||
|
rightIcon,
|
||||||
|
rightText,
|
||||||
|
rightTextColor,
|
||||||
|
rightAction,
|
||||||
|
customTitleVisible,
|
||||||
|
customRightVisible,
|
||||||
|
isShowing,
|
||||||
|
canBack,
|
||||||
|
setHeight,
|
||||||
|
setBackTo,
|
||||||
|
setRightIcon,
|
||||||
|
setRightText,
|
||||||
|
setCustomTitleVisible,
|
||||||
|
setCustomRightVisible,
|
||||||
|
clearRight
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import {
|
||||||
|
OrderType,
|
||||||
|
PermissionAPI,
|
||||||
|
RoleAPI,
|
||||||
|
type ModuleCode,
|
||||||
|
type Permission as UserPermission,
|
||||||
|
type PermissionPayload,
|
||||||
|
type Role as UserRole,
|
||||||
|
type RolePayload
|
||||||
|
} from "timi-web";
|
||||||
|
|
||||||
|
export const gaoModuleCode: ModuleCode = "GAO";
|
||||||
|
const pageSize = 1000;
|
||||||
|
|
||||||
|
export type Permission = UserPermission;
|
||||||
|
export type Role = UserRole & {
|
||||||
|
permissionIdList: string[];
|
||||||
|
childRoleIdList: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoleDraft = {
|
||||||
|
id?: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
nameLangId?: string;
|
||||||
|
moduleCode?: ModuleCode;
|
||||||
|
permissionIdList: string[];
|
||||||
|
childRoleIdList: string[];
|
||||||
|
};
|
||||||
|
export type PermissionDraft = {
|
||||||
|
id?: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
nameLangId?: string;
|
||||||
|
moduleCode?: ModuleCode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRole(role: UserRole): Role {
|
||||||
|
return {
|
||||||
|
...role,
|
||||||
|
moduleCode: role.moduleCode || gaoModuleCode,
|
||||||
|
description: role.description || "",
|
||||||
|
permissionIdList: role.permissionIdList || role.permissionList?.map(item => item.id) || [],
|
||||||
|
childRoleIdList: role.childRoleIdList || role.childRoleList?.map(item => item.id) || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyRole(): Role {
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
moduleCode: gaoModuleCode,
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
permissionIdList: [],
|
||||||
|
childRoleIdList: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyPermission(): Permission {
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
moduleCode: gaoModuleCode,
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
description: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePermissionSettingsStore = defineStore("permission-settings", () => {
|
||||||
|
const roleList = ref<Role[]>([]);
|
||||||
|
const permissionList = ref<Permission[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const loaded = ref(false);
|
||||||
|
let loadTask: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const roleTree = computed<Array<Role & { level: number }>>(() => {
|
||||||
|
const childIdSet = new Set(roleList.value.flatMap(item => item.childRoleIdList));
|
||||||
|
const roots = roleList.value.filter(item => !childIdSet.has(item.id));
|
||||||
|
return roots.flatMap(item => buildRoleTree(item.id, 0, new Set<string>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 基础数据 ----------
|
||||||
|
|
||||||
|
async function loadAll(force = false): Promise<void> {
|
||||||
|
if (loaded.value && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadTask && !force) {
|
||||||
|
await loadTask;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
loadTask = (async () => {
|
||||||
|
const [roleResult, permissionResult] = await Promise.all([
|
||||||
|
RoleAPI.list({
|
||||||
|
index: 0,
|
||||||
|
size: pageSize,
|
||||||
|
equalsExample: {
|
||||||
|
moduleCode: gaoModuleCode
|
||||||
|
},
|
||||||
|
orderMap: {
|
||||||
|
code: OrderType.ASC
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
PermissionAPI.list({
|
||||||
|
index: 0,
|
||||||
|
size: pageSize,
|
||||||
|
equalsExample: {
|
||||||
|
moduleCode: gaoModuleCode
|
||||||
|
},
|
||||||
|
orderMap: {
|
||||||
|
code: OrderType.ASC
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
roleList.value = roleResult.list.map(normalizeRole);
|
||||||
|
permissionList.value = permissionResult.list;
|
||||||
|
loaded.value = true;
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadTask;
|
||||||
|
} finally {
|
||||||
|
loadTask = null;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoleDetail(id: string): Promise<Role> {
|
||||||
|
if (!id) {
|
||||||
|
return createEmptyRole();
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = normalizeRole(await RoleAPI.detail(id));
|
||||||
|
upsertLocalRole(role);
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPermissionDetail(id: string): Promise<Permission> {
|
||||||
|
if (!id) {
|
||||||
|
return createEmptyPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = permissionList.value.find(item => item.id === id);
|
||||||
|
if (!permission) {
|
||||||
|
throw new Error("权限不存在");
|
||||||
|
}
|
||||||
|
upsertLocalPermission(permission);
|
||||||
|
return permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertLocalRole(role: Role): void {
|
||||||
|
const index = roleList.value.findIndex(item => item.id === role.id);
|
||||||
|
if (-1 < index) {
|
||||||
|
roleList.value.splice(index, 1, role);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
roleList.value.unshift(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertLocalPermission(permission: Permission): void {
|
||||||
|
const index = permissionList.value.findIndex(item => item.id === permission.id);
|
||||||
|
if (-1 < index) {
|
||||||
|
permissionList.value.splice(index, 1, permission);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
permissionList.value.unshift(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 查询 ----------
|
||||||
|
|
||||||
|
function buildRoleTree(roleId: string, level: number, visitedRoleIds: Set<string>): Array<Role & { level: number }> {
|
||||||
|
const current = getRoleById(roleId);
|
||||||
|
if (!current || visitedRoleIds.has(roleId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextVisitedRoleIds = new Set(visitedRoleIds);
|
||||||
|
nextVisitedRoleIds.add(roleId);
|
||||||
|
|
||||||
|
return [{
|
||||||
|
...current,
|
||||||
|
level
|
||||||
|
}, ...current.childRoleIdList.flatMap(childRoleId => buildRoleTree(childRoleId, level + 1, nextVisitedRoleIds))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleById(id: string): Role | undefined {
|
||||||
|
return roleList.value.find(item => item.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissionById(id: string): Permission | undefined {
|
||||||
|
return permissionList.value.find(item => item.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChildRoles(role: Role): Role[] {
|
||||||
|
return role.childRoleIdList.map(getRoleById).filter(Boolean) as Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRolePermissionList(role: Role): Permission[] {
|
||||||
|
return role.permissionIdList.map(getPermissionById).filter(Boolean) as Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInheritedPermissionList(role: Role): Permission[] {
|
||||||
|
const permissionMap = new Map<string, Permission>();
|
||||||
|
const allPermissionList = role.allPermissionList || [];
|
||||||
|
for (const permission of [...getRolePermissionList(role), ...allPermissionList]) {
|
||||||
|
permissionMap.set(permission.id, permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const childRole of getChildRoles(role)) {
|
||||||
|
for (const permission of getRolePermissionList(childRole)) {
|
||||||
|
permissionMap.set(permission.id, permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(permissionMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 写入 ----------
|
||||||
|
|
||||||
|
async function upsertRole(draft: RoleDraft, createMode: boolean): Promise<Role | undefined> {
|
||||||
|
const current = getRoleById(draft.id || "");
|
||||||
|
const payload: RolePayload = {
|
||||||
|
...draft,
|
||||||
|
moduleCode: gaoModuleCode,
|
||||||
|
code: draft.code.trim(),
|
||||||
|
nameLangId: current?.nameLangId,
|
||||||
|
name: draft.name.trim(),
|
||||||
|
description: draft.description.trim(),
|
||||||
|
permissionIdList: [...draft.permissionIdList],
|
||||||
|
childRoleIdList: [...draft.childRoleIdList]
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createMode) {
|
||||||
|
await RoleAPI.create({
|
||||||
|
moduleCode: payload.moduleCode,
|
||||||
|
code: payload.code,
|
||||||
|
name: payload.name,
|
||||||
|
description: payload.description,
|
||||||
|
permissionIdList: payload.permissionIdList,
|
||||||
|
childRoleIdList: payload.childRoleIdList
|
||||||
|
});
|
||||||
|
await loadAll(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RoleAPI.update(payload);
|
||||||
|
const role = await loadRoleDetail(payload.id || "");
|
||||||
|
await loadAll(true);
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRole(id: string): Promise<void> {
|
||||||
|
await RoleAPI.remove(id);
|
||||||
|
roleList.value = roleList.value
|
||||||
|
.filter(item => item.id !== id)
|
||||||
|
.map(item => ({
|
||||||
|
...item,
|
||||||
|
childRoleIdList: item.childRoleIdList.filter(childId => childId !== id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertPermission(draft: PermissionDraft, createMode: boolean): Promise<Permission | undefined> {
|
||||||
|
const current = getPermissionById(draft.id || "");
|
||||||
|
const payload: PermissionPayload = {
|
||||||
|
...draft,
|
||||||
|
moduleCode: gaoModuleCode,
|
||||||
|
code: draft.code.trim(),
|
||||||
|
nameLangId: current?.nameLangId,
|
||||||
|
name: draft.name.trim(),
|
||||||
|
description: draft.description.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createMode) {
|
||||||
|
await PermissionAPI.create({
|
||||||
|
moduleCode: payload.moduleCode,
|
||||||
|
code: payload.code,
|
||||||
|
name: payload.name,
|
||||||
|
description: payload.description
|
||||||
|
});
|
||||||
|
await loadAll(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
await PermissionAPI.update(payload);
|
||||||
|
const permission = await loadPermissionDetail(payload.id || "");
|
||||||
|
await loadAll(true);
|
||||||
|
return permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePermission(id: string): Promise<void> {
|
||||||
|
await PermissionAPI.remove(id);
|
||||||
|
permissionList.value = permissionList.value.filter(item => item.id !== id);
|
||||||
|
roleList.value = roleList.value.map(item => ({
|
||||||
|
...item,
|
||||||
|
permissionIdList: item.permissionIdList.filter(permissionId => permissionId !== id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRolePermissionList(roleId: string, permissionIds: string[]): Promise<void> {
|
||||||
|
const role = await loadRoleDetail(roleId);
|
||||||
|
await upsertRole({
|
||||||
|
id: role.id,
|
||||||
|
moduleCode: gaoModuleCode,
|
||||||
|
code: role.code,
|
||||||
|
name: role.name || "",
|
||||||
|
description: role.description || "",
|
||||||
|
permissionIdList: [...permissionIds],
|
||||||
|
childRoleIdList: [...role.childRoleIdList]
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
roleList,
|
||||||
|
permissionList,
|
||||||
|
roleTree,
|
||||||
|
loading,
|
||||||
|
loaded,
|
||||||
|
loadAll,
|
||||||
|
loadRoleDetail,
|
||||||
|
loadPermissionDetail,
|
||||||
|
getRoleById,
|
||||||
|
getPermissionById,
|
||||||
|
getChildRoles,
|
||||||
|
getRolePermissionList,
|
||||||
|
getInheritedPermissionList,
|
||||||
|
upsertRole,
|
||||||
|
removeRole,
|
||||||
|
upsertPermission,
|
||||||
|
removePermission,
|
||||||
|
setRolePermissionList
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import scanSuccessSoundUrl from "@/assets/sound/scan-success.mp3";
|
||||||
|
import registrationSuccessSoundUrl from "@/assets/sound/registration-success.mp3";
|
||||||
|
|
||||||
|
export interface SoundSetting {
|
||||||
|
scanSuccess: boolean;
|
||||||
|
registerSuccess: boolean;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SoundType = "scanSuccess" | "registerSuccess";
|
||||||
|
|
||||||
|
const SOUND_STORAGE_KEY = "gao-customer-h5:sound";
|
||||||
|
const soundUrlMap: Record<SoundType, string> = {
|
||||||
|
scanSuccess: scanSuccessSoundUrl,
|
||||||
|
registerSuccess: registrationSuccessSoundUrl
|
||||||
|
};
|
||||||
|
// Windows 某些设备链路对极短提示音的起音处理不稳定,直接播放会偏小。
|
||||||
|
// 这里先补一段极低音量的预热头,把输出链路“叫醒”后再接原音效。
|
||||||
|
const SCAN_SUCCESS_WARMUP_HEAD_MS = 120;
|
||||||
|
const SCAN_SUCCESS_WARMUP_GAIN = .08;
|
||||||
|
|
||||||
|
function createDefaultSoundSetting(): SoundSetting {
|
||||||
|
return {
|
||||||
|
scanSuccess: true,
|
||||||
|
registerSuccess: true,
|
||||||
|
volume: 70
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredSoundSetting(): SoundSetting {
|
||||||
|
const rawValue = localStorage.getItem(SOUND_STORAGE_KEY);
|
||||||
|
if (!rawValue) {
|
||||||
|
return createDefaultSoundSetting();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedValue = JSON.parse(rawValue) as Partial<SoundSetting>;
|
||||||
|
const volume = Number(parsedValue.volume);
|
||||||
|
return {
|
||||||
|
scanSuccess: parsedValue.scanSuccess !== false,
|
||||||
|
registerSuccess: parsedValue.registerSuccess !== false,
|
||||||
|
volume: Number.isFinite(volume) ? Math.min(100, Math.max(0, volume)) : 70
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return createDefaultSoundSetting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingStore = defineStore("setting", () => {
|
||||||
|
const sound = ref<SoundSetting>(readStoredSoundSetting());
|
||||||
|
const soundPlayerMap = shallowRef<Record<SoundType, HTMLAudioElement> | null>(null);
|
||||||
|
const audioContext = shallowRef<AudioContext | null>(null);
|
||||||
|
const scanSuccessBuffer = shallowRef<AudioBuffer | null>(null);
|
||||||
|
const activeScanSuccessSource = shallowRef<AudioBufferSourceNode | null>(null);
|
||||||
|
const soundUnlocked = ref(false);
|
||||||
|
|
||||||
|
function setSound(value: Partial<SoundSetting>): void {
|
||||||
|
sound.value = {
|
||||||
|
scanSuccess: value.scanSuccess ?? sound.value.scanSuccess,
|
||||||
|
registerSuccess: value.registerSuccess ?? sound.value.registerSuccess,
|
||||||
|
volume: Math.min(100, Math.max(0, value.volume ?? sound.value.volume))
|
||||||
|
};
|
||||||
|
localStorage.setItem(SOUND_STORAGE_KEY, JSON.stringify(sound.value));
|
||||||
|
applySoundVolume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSoundPlayers(): void {
|
||||||
|
if (soundPlayerMap.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
soundPlayerMap.value = {
|
||||||
|
scanSuccess: createSoundPlayer(soundUrlMap.scanSuccess),
|
||||||
|
registerSuccess: createSoundPlayer(soundUrlMap.registerSuccess)
|
||||||
|
};
|
||||||
|
applySoundVolume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAudioContext(): AudioContext {
|
||||||
|
if (audioContext.value) {
|
||||||
|
return audioContext.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioContext.value = new window.AudioContext();
|
||||||
|
return audioContext.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSoundPlayer(url: string): HTMLAudioElement {
|
||||||
|
const player = new Audio(url);
|
||||||
|
player.preload = "auto";
|
||||||
|
player.load();
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySoundVolume(): void {
|
||||||
|
if (!soundPlayerMap.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const volume = sound.value.volume / 100;
|
||||||
|
Object.values(soundPlayerMap.value).forEach((player) => {
|
||||||
|
player.volume = volume;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlockSoundPlayers(): Promise<boolean> {
|
||||||
|
loadSoundPlayers();
|
||||||
|
if (soundUnlocked.value || !soundPlayerMap.value) {
|
||||||
|
return soundUnlocked.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getAudioContext().resume();
|
||||||
|
for (const player of Object.values(soundPlayerMap.value)) {
|
||||||
|
player.muted = true;
|
||||||
|
player.volume = 0;
|
||||||
|
player.currentTime = 0;
|
||||||
|
await player.play();
|
||||||
|
player.pause();
|
||||||
|
player.currentTime = 0;
|
||||||
|
player.muted = false;
|
||||||
|
}
|
||||||
|
applySoundVolume();
|
||||||
|
soundUnlocked.value = true;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
Object.values(soundPlayerMap.value).forEach((player) => {
|
||||||
|
player.muted = false;
|
||||||
|
});
|
||||||
|
applySoundVolume();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScanSuccessBuffer(): Promise<AudioBuffer> {
|
||||||
|
if (scanSuccessBuffer.value) {
|
||||||
|
return scanSuccessBuffer.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(soundUrlMap.scanSuccess);
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
scanSuccessBuffer.value = await getAudioContext().decodeAudioData(arrayBuffer.slice(0));
|
||||||
|
return scanSuccessBuffer.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playScanSuccessBufferSound(): Promise<void> {
|
||||||
|
const context = getAudioContext();
|
||||||
|
if (context.state === "suspended") {
|
||||||
|
await context.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
activeScanSuccessSource.value?.stop();
|
||||||
|
activeScanSuccessSource.value?.disconnect();
|
||||||
|
|
||||||
|
const source = context.createBufferSource();
|
||||||
|
const gainNode = context.createGain();
|
||||||
|
source.buffer = await buildScanSuccessPlaybackBuffer();
|
||||||
|
gainNode.gain.value = sound.value.volume / 100;
|
||||||
|
source.connect(gainNode);
|
||||||
|
gainNode.connect(context.destination);
|
||||||
|
source.onended = () => {
|
||||||
|
if (activeScanSuccessSource.value === source) {
|
||||||
|
activeScanSuccessSource.value = null;
|
||||||
|
}
|
||||||
|
source.disconnect();
|
||||||
|
gainNode.disconnect();
|
||||||
|
};
|
||||||
|
activeScanSuccessSource.value = source;
|
||||||
|
source.start(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildScanSuccessPlaybackBuffer(): Promise<AudioBuffer> {
|
||||||
|
const sourceBuffer = await loadScanSuccessBuffer();
|
||||||
|
const context = getAudioContext();
|
||||||
|
const warmupFrameCount = Math.max(1, Math.round(context.sampleRate * SCAN_SUCCESS_WARMUP_HEAD_MS / 1000));
|
||||||
|
const outputBuffer = context.createBuffer(
|
||||||
|
sourceBuffer.numberOfChannels,
|
||||||
|
sourceBuffer.length + warmupFrameCount,
|
||||||
|
sourceBuffer.sampleRate
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let channelIndex = 0; channelIndex < sourceBuffer.numberOfChannels; channelIndex += 1) {
|
||||||
|
const outputData = outputBuffer.getChannelData(channelIndex);
|
||||||
|
const sourceData = sourceBuffer.getChannelData(channelIndex);
|
||||||
|
const warmupSourceLength = Math.min(sourceData.length, warmupFrameCount);
|
||||||
|
// 预热头不能是纯静音;纯静音对这类 Windows 问题无效。
|
||||||
|
for (let frameIndex = 0; frameIndex < warmupFrameCount; frameIndex += 1) {
|
||||||
|
const sourceIndex = frameIndex % warmupSourceLength;
|
||||||
|
outputData[frameIndex] = sourceData[sourceIndex] * SCAN_SUCCESS_WARMUP_GAIN;
|
||||||
|
}
|
||||||
|
outputData.set(sourceData, warmupFrameCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playSound(type: SoundType): Promise<void> {
|
||||||
|
if (type === "scanSuccess") {
|
||||||
|
await playScanSuccessBufferSound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSoundPlayers();
|
||||||
|
if (!sound.value[type] || !soundPlayerMap.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = soundPlayerMap.value[type];
|
||||||
|
player.pause();
|
||||||
|
player.muted = false;
|
||||||
|
player.volume = sound.value.volume / 100;
|
||||||
|
player.currentTime = 0;
|
||||||
|
await player.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playScanSuccessSound(): Promise<void> {
|
||||||
|
await playSound("scanSuccess");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playRegisterSuccessSound(): Promise<void> {
|
||||||
|
await playSound("registerSuccess");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sound,
|
||||||
|
setSound,
|
||||||
|
loadSoundPlayers,
|
||||||
|
unlockSoundPlayers,
|
||||||
|
playScanSuccessSound,
|
||||||
|
playRegisterSuccessSound
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const useTabBarStore = defineStore("tab-bar", () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isShowing = computed(() => !!router.currentRoute.value.meta.tabBarVisible);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isShowing
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
--td-swiper-border-radius: 0;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
background: #F5F5F8;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
background: #10161f;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: #111827;
|
||||||
|
background: #F5F5F8;
|
||||||
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
transition: background .2s ease, color .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-dark body {
|
||||||
|
color: #f3f4f6;
|
||||||
|
background: #10161f;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-form__label {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
:root, :root[theme-mode="light"] {
|
||||||
|
--td-brand-color-1: #ebf9f6;
|
||||||
|
--td-brand-color-2: #ceebe6;
|
||||||
|
--td-brand-color-3: #a4d5ce;
|
||||||
|
--td-brand-color-4: #76bbb3;
|
||||||
|
--td-brand-color-5: #559f97;
|
||||||
|
--td-brand-color-6: #29857d;
|
||||||
|
--td-brand-color-7: #0f766e;
|
||||||
|
--td-brand-color-8: #00524b;
|
||||||
|
--td-brand-color-9: #003b36;
|
||||||
|
--td-brand-color-10: #002723;
|
||||||
|
--td-warning-color-1: #fff1e9;
|
||||||
|
--td-warning-color-2: #ffd9c2;
|
||||||
|
--td-warning-color-3: #ffb98c;
|
||||||
|
--td-warning-color-4: #fa9550;
|
||||||
|
--td-warning-color-5: #e37318;
|
||||||
|
--td-warning-color-6: #be5a00;
|
||||||
|
--td-warning-color-7: #954500;
|
||||||
|
--td-warning-color-8: #713300;
|
||||||
|
--td-warning-color-9: #532300;
|
||||||
|
--td-warning-color-10: #3b1700;
|
||||||
|
--td-error-color-1: #fff0ed;
|
||||||
|
--td-error-color-2: #ffd8d2;
|
||||||
|
--td-error-color-3: #ffb9b0;
|
||||||
|
--td-error-color-4: #ff9285;
|
||||||
|
--td-error-color-5: #f6685d;
|
||||||
|
--td-error-color-6: #d54941;
|
||||||
|
--td-error-color-7: #ad352f;
|
||||||
|
--td-error-color-8: #881f1c;
|
||||||
|
--td-error-color-9: #68070a;
|
||||||
|
--td-error-color-10: #490002;
|
||||||
|
--td-success-color-1: #e3f9e9;
|
||||||
|
--td-success-color-2: #c6f3d7;
|
||||||
|
--td-success-color-3: #92dab2;
|
||||||
|
--td-success-color-4: #56c08d;
|
||||||
|
--td-success-color-5: #2ba471;
|
||||||
|
--td-success-color-6: #008858;
|
||||||
|
--td-success-color-7: #006c45;
|
||||||
|
--td-success-color-8: #005334;
|
||||||
|
--td-success-color-9: #003b23;
|
||||||
|
--td-success-color-10: #002515;
|
||||||
|
--td-gray-color-1: #f6f6f6;
|
||||||
|
--td-gray-color-2: #eeeceb;
|
||||||
|
--td-gray-color-3: #dddbdb;
|
||||||
|
--td-gray-color-4: #cac9c9;
|
||||||
|
--td-gray-color-5: #b6b6b6;
|
||||||
|
--td-gray-color-6: #a2a2a2;
|
||||||
|
--td-gray-color-7: #8f8f8f;
|
||||||
|
--td-gray-color-8: #7b7c7c;
|
||||||
|
--td-gray-color-9: #6a6b6b;
|
||||||
|
--td-gray-color-10: #585a5a;
|
||||||
|
--td-gray-color-11: #494a4a;
|
||||||
|
--td-gray-color-12: #3a3b3b;
|
||||||
|
--td-gray-color-13: #2c2e2e;
|
||||||
|
--td-gray-color-14: #202222;
|
||||||
|
--td-font-white-1: rgba(255, 255, 255, 1);
|
||||||
|
--td-font-white-2: rgba(255, 255, 255, .55);
|
||||||
|
--td-font-white-3: rgba(255, 255, 255, .35);
|
||||||
|
--td-font-white-4: rgba(255, 255, 255, .22);
|
||||||
|
--td-font-gray-1: rgba(0, 0, 0, .9);
|
||||||
|
--td-font-gray-2: rgba(0, 0, 0, .6);
|
||||||
|
--td-font-gray-3: rgba(0, 0, 0, .4);
|
||||||
|
--td-font-gray-4: rgba(0, 0, 0, .26);
|
||||||
|
--td-brand-color: var(--td-brand-color-7);
|
||||||
|
--td-warning-color: var(--td-warning-color-5);
|
||||||
|
--td-error-color: var(--td-error-color-6);
|
||||||
|
--td-success-color: var(--td-success-color-5);
|
||||||
|
--td-brand-color-focus: var(--td-brand-color-1);
|
||||||
|
--td-brand-color-active: var(--td-brand-color-8);
|
||||||
|
--td-brand-color-disabled: var(--td-brand-color-3);
|
||||||
|
--td-brand-color-light: var(--td-brand-color-1);
|
||||||
|
--td-brand-color-light-active: var(--td-brand-color-2);
|
||||||
|
--td-warning-color-active: var(--td-warning-color-6);
|
||||||
|
--td-warning-color-disabled: var(--td-warning-color-3);
|
||||||
|
--td-warning-color-focus: var(--td-warning-color-2);
|
||||||
|
--td-warning-color-light: var(--td-warning-color-1);
|
||||||
|
--td-warning-color-light-active: var(--td-warning-color-2);
|
||||||
|
--td-error-color-focus: var(--td-error-color-2);
|
||||||
|
--td-error-color-active: var(--td-error-color-7);
|
||||||
|
--td-error-color-disabled: var(--td-error-color-3);
|
||||||
|
--td-error-color-light: var(--td-error-color-1);
|
||||||
|
--td-error-color-light-active: var(--td-error-color-2);
|
||||||
|
--td-success-color-focus: var(--td-success-color-2);
|
||||||
|
--td-success-color-active: var(--td-success-color-6);
|
||||||
|
--td-success-color-disabled: var(--td-success-color-3);
|
||||||
|
--td-success-color-light: var(--td-success-color-1);
|
||||||
|
--td-success-color-light-active: var(--td-success-color-2);
|
||||||
|
--td-mask-active: rgba(0, 0, 0, .6);
|
||||||
|
--td-mask-disabled: rgba(255, 255, 255, .6);
|
||||||
|
--td-bg-color-page: var(--td-gray-color-1);
|
||||||
|
--td-bg-color-container: var(--td-font-white-1);
|
||||||
|
--td-bg-color-container-active: var(--td-gray-color-3);
|
||||||
|
--td-bg-color-secondarycontainer: var(--td-gray-color-1);
|
||||||
|
--td-bg-color-secondarycontainer-active: var(--td-gray-color-4);
|
||||||
|
--td-bg-color-component: var(--td-gray-color-3);
|
||||||
|
--td-bg-color-component-active: var(--td-gray-color-6);
|
||||||
|
--td-bg-color-component-disabled: var(--td-gray-color-2);
|
||||||
|
--td-bg-color-secondarycomponent: var(--td-gray-color-4);
|
||||||
|
--td-bg-color-secondarycomponent-active: var(--td-gray-color-6);
|
||||||
|
--td-bg-color-specialcomponent: #fff;
|
||||||
|
--td-text-color-primary: var(--td-font-gray-1);
|
||||||
|
--td-text-color-secondary: var(--td-font-gray-2);
|
||||||
|
--td-text-color-placeholder: var(--td-font-gray-3);
|
||||||
|
--td-text-color-disabled: var(--td-font-gray-4);
|
||||||
|
--td-text-color-anti: var(--td-font-white-1);
|
||||||
|
--td-text-color-brand: var(--td-brand-color);
|
||||||
|
--td-text-color-link: var(--td-brand-color);
|
||||||
|
--td-border-level-1-color: var(--td-gray-color-3);
|
||||||
|
--td-component-stroke: var(--td-gray-color-3);
|
||||||
|
--td-border-level-2-color: var(--td-gray-color-4);
|
||||||
|
--td-component-border: var(--td-gray-color-4);
|
||||||
|
--td-shadow-1: 0 1px 6px rgba(0, 0, 0, 0.05),0 3px 4px rgba(0, 0, 0, 0.04),0 1px 2px -1px rgba(0, 0, 0, 0.08);
|
||||||
|
--td-shadow-2: 0 3px 10px 2px rgba(0, 0, 0, 0.04),0 6px 8px rgba(0, 0, 0, 0.05),0 4px 4px -2px rgba(0, 0, 0, 0.08);
|
||||||
|
--td-shadow-3: 0 4px 18px 5px rgba(0, 0, 0, 0.05),0 10px 9px 2px rgba(0, 0, 0, 0.04),0 3px 5px -3px rgba(0, 0, 0, 0.08);
|
||||||
|
--td-shadow-4: 0 2px 8px 0 rgba(0, 0, 0, 6%);
|
||||||
|
--td-shadow-inset-top: inset 0 .5px 0 #dcdcdc;
|
||||||
|
--td-shadow-inset-right: inset .5px 0 0 #dcdcdc;
|
||||||
|
--td-shadow-inset-bottom: inset 0 -.5px 0 #dcdcdc;
|
||||||
|
--td-shadow-inset-left: inset -.5px 0 0 #dcdcdc;
|
||||||
|
--td-table-shadow-color: rgba(0, 0, 0, .08);
|
||||||
|
--td-scrollbar-color: rgba(0, 0, 0, .1);
|
||||||
|
--td-scrollbar-hover-color: rgba(0, 0, 0, .3);
|
||||||
|
--td-scroll-track-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark, :root[theme-mode="dark"] {
|
||||||
|
--td-brand-color-1: #002723;
|
||||||
|
--td-brand-color-2: #003b36;
|
||||||
|
--td-brand-color-3: #00524b;
|
||||||
|
--td-brand-color-4: #0f766e;
|
||||||
|
--td-brand-color-5: #29857d;
|
||||||
|
--td-brand-color-6: #559f97;
|
||||||
|
--td-brand-color-7: #76bbb3;
|
||||||
|
--td-brand-color-8: #a4d5ce;
|
||||||
|
--td-brand-color-9: #ceebe6;
|
||||||
|
--td-brand-color-10: #ebf9f6;
|
||||||
|
--td-warning-color-1: #4f2a1d;
|
||||||
|
--td-warning-color-2: #582f21;
|
||||||
|
--td-warning-color-3: #733c23;
|
||||||
|
--td-warning-color-4: #a75d2b;
|
||||||
|
--td-warning-color-5: #cf6e2d;
|
||||||
|
--td-warning-color-6: #dc7633;
|
||||||
|
--td-warning-color-7: #e8935c;
|
||||||
|
--td-warning-color-8: #ecbf91;
|
||||||
|
--td-warning-color-9: #eed7bf;
|
||||||
|
--td-warning-color-10: #f3e9dc;
|
||||||
|
--td-error-color-1: #472324;
|
||||||
|
--td-error-color-2: #5e2a2d;
|
||||||
|
--td-error-color-3: #703439;
|
||||||
|
--td-error-color-4: #83383e;
|
||||||
|
--td-error-color-5: #a03f46;
|
||||||
|
--td-error-color-6: #c64751;
|
||||||
|
--td-error-color-7: #de6670;
|
||||||
|
--td-error-color-8: #ec888e;
|
||||||
|
--td-error-color-9: #edb1b6;
|
||||||
|
--td-error-color-10: #eeced0;
|
||||||
|
--td-success-color-1: #193a2a;
|
||||||
|
--td-success-color-2: #1a4230;
|
||||||
|
--td-success-color-3: #17533d;
|
||||||
|
--td-success-color-4: #0d7a55;
|
||||||
|
--td-success-color-5: #059465;
|
||||||
|
--td-success-color-6: #43af8a;
|
||||||
|
--td-success-color-7: #46bf96;
|
||||||
|
--td-success-color-8: #80d2b6;
|
||||||
|
--td-success-color-9: #b4e1d3;
|
||||||
|
--td-success-color-10: #deede8;
|
||||||
|
--td-gray-color-1: #f6f6f6;
|
||||||
|
--td-gray-color-2: #eeeceb;
|
||||||
|
--td-gray-color-3: #dddbdb;
|
||||||
|
--td-gray-color-4: #cac9c9;
|
||||||
|
--td-gray-color-5: #b6b6b6;
|
||||||
|
--td-gray-color-6: #a2a2a2;
|
||||||
|
--td-gray-color-7: #8f8f8f;
|
||||||
|
--td-gray-color-8: #7b7c7c;
|
||||||
|
--td-gray-color-9: #6a6b6b;
|
||||||
|
--td-gray-color-10: #585a5a;
|
||||||
|
--td-gray-color-11: #494a4a;
|
||||||
|
--td-gray-color-12: #3a3b3b;
|
||||||
|
--td-gray-color-13: #2c2e2e;
|
||||||
|
--td-gray-color-14: #202222;
|
||||||
|
--td-font-white-1: rgba(255, 255, 255, .9);
|
||||||
|
--td-font-white-2: rgba(255, 255, 255, .55);
|
||||||
|
--td-font-white-3: rgba(255, 255, 255, .35);
|
||||||
|
--td-font-white-4: rgba(255, 255, 255, .22);
|
||||||
|
--td-font-gray-1: rgba(0, 0, 0, .9);
|
||||||
|
--td-font-gray-2: rgba(0, 0, 0, .6);
|
||||||
|
--td-font-gray-3: rgba(0, 0, 0, .4);
|
||||||
|
--td-font-gray-4: rgba(0, 0, 0, .26);
|
||||||
|
--td-brand-color: var(--td-brand-color-6);
|
||||||
|
--td-warning-color: var(--td-warning-color-5);
|
||||||
|
--td-error-color: var(--td-error-color-6);
|
||||||
|
--td-success-color: var(--td-success-color-5);
|
||||||
|
--td-brand-color-focus: var(--td-brand-color-1);
|
||||||
|
--td-brand-color-active: var(--td-brand-color-7);
|
||||||
|
--td-brand-color-disabled: var(--td-brand-color-3);
|
||||||
|
--td-brand-color-light: var(--td-brand-color-1);
|
||||||
|
--td-brand-color-light-active: var(--td-brand-color-2);
|
||||||
|
--td-warning-color-focus: var(--td-warning-color-2);
|
||||||
|
--td-warning-color-active: var(--td-warning-color-4);
|
||||||
|
--td-warning-color-disabled: var(--td-warning-color-3);
|
||||||
|
--td-warning-color-light: var(--td-warning-color-1);
|
||||||
|
--td-warning-color-light-active: var(--td-warning-color-2);
|
||||||
|
--td-error-color-focus: var(--td-error-color-2);
|
||||||
|
--td-error-color-active: var(--td-error-color-5);
|
||||||
|
--td-error-color-disabled: var(--td-error-color-3);
|
||||||
|
--td-error-color-light: var(--td-error-color-1);
|
||||||
|
--td-error-color-light-active: var(--td-error-color-2);
|
||||||
|
--td-success-color-focus: var(--td-success-color-2);
|
||||||
|
--td-success-color-active: var(--td-success-color-4);
|
||||||
|
--td-success-color-disabled: var(--td-success-color-3);
|
||||||
|
--td-success-color-light: var(--td-success-color-1);
|
||||||
|
--td-success-color-light-active: var(--td-success-color-2);
|
||||||
|
--td-mask-active: rgba(0, 0, 0, .4);
|
||||||
|
--td-mask-disabled: rgba(0, 0, 0, .6);
|
||||||
|
--td-bg-color-page: var(--td-gray-color-14);
|
||||||
|
--td-bg-color-container: var(--td-gray-color-13);
|
||||||
|
--td-bg-color-secondarycontainer: var(--td-gray-color-12);
|
||||||
|
--td-bg-color-component: var(--td-gray-color-11);
|
||||||
|
--td-bg-color-container-active: var(--td-gray-color-12);
|
||||||
|
--td-bg-color-secondarycontainer-active: var(--td-gray-color-11);
|
||||||
|
--td-bg-color-component-active: var(--td-gray-color-10);
|
||||||
|
--td-bg-color-component-disabled: var(--td-gray-color-12);
|
||||||
|
--td-bg-color-specialcomponent: transparent;
|
||||||
|
--td-text-color-primary: var(--td-font-white-1);
|
||||||
|
--td-text-color-secondary: var(--td-font-white-2);
|
||||||
|
--td-text-color-placeholder: var(--td-font-white-3);
|
||||||
|
--td-text-color-disabled: var(--td-font-white-4);
|
||||||
|
--td-text-color-anti: var(--td-font-white-1);
|
||||||
|
--td-text-color-brand: var(--td-brand-color-8);
|
||||||
|
--td-text-color-link: var(--td-brand-color-8);
|
||||||
|
--td-border-level-1-color: var(--td-gray-color-11);
|
||||||
|
--td-component-stroke: var(--td-gray-color-11);
|
||||||
|
--td-border-level-2-color: var(--td-gray-color-9);
|
||||||
|
--td-component-border: var(--td-gray-color-9);
|
||||||
|
--td-shadow-1: 0 1px 6px rgba(0, 0, 0, 0.05),0 3px 4px rgba(0, 0, 0, 0.04),0 1px 2px -1px rgba(0, 0, 0, 0.08);
|
||||||
|
--td-shadow-2: 0 3px 10px 2px rgba(0, 0, 0, 0.04),0 6px 8px rgba(0, 0, 0, 0.05),0 4px 4px -2px rgba(0, 0, 0, 0.08);
|
||||||
|
--td-shadow-3: 0 4px 18px 5px rgba(0, 0, 0, 0.05),0 10px 9px 2px rgba(0, 0, 0, 0.04),0 3px 5px -3px rgba(0, 0, 0, 0.08);
|
||||||
|
--td-shadow-inset-top: inset 0 .5px 0 #5e5e5e;
|
||||||
|
--td-shadow-inset-right: inset .5px 0 0 #5e5e5e;
|
||||||
|
--td-shadow-inset-bottom: inset 0 -.5px 0 #5e5e5e;
|
||||||
|
--td-shadow-inset-left: inset -.5px 0 0 #5e5e5e;
|
||||||
|
--td-table-shadow-color: rgba(0, 0, 0, .55);
|
||||||
|
--td-scrollbar-color: rgba(255, 255, 255, .1);
|
||||||
|
--td-scroll-track-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--td-font-family: pingfang sc, microsoft yahei, arial regular;
|
||||||
|
--td-font-family-medium: pingfang sc, microsoft yahei, arial medium;
|
||||||
|
--td-font-size-link-small: 12px;
|
||||||
|
--td-font-size-link-medium: 14px;
|
||||||
|
--td-font-size-link-large: 16px;
|
||||||
|
--td-font-size-mark-extraSmall: 10px;
|
||||||
|
--td-font-size-mark-small: 12px;
|
||||||
|
--td-font-size-mark-medium: 14px;
|
||||||
|
--td-font-size-mark-large: 16px;
|
||||||
|
--td-font-size-body-extraSmall: 10px;
|
||||||
|
--td-font-size-body-small: 12px;
|
||||||
|
--td-font-size-body-medium: 14px;
|
||||||
|
--td-font-size-body-large: 16px;
|
||||||
|
--td-font-size-title-small: 14px;
|
||||||
|
--td-font-size-title-medium: 16px;
|
||||||
|
--td-font-size-title-large: 18px;
|
||||||
|
--td-font-size-title-extraLarge: 20px;
|
||||||
|
--td-font-size-headline-small: 24px;
|
||||||
|
--td-font-size-headline-medium: 28px;
|
||||||
|
--td-font-size-headline-large: 36px;
|
||||||
|
--td-font-size-display-medium: 48px;
|
||||||
|
--td-font-size-display-large: 64px;
|
||||||
|
--td-font-size: 10px;
|
||||||
|
--td-font-size-xs: var(--td-font-size-body-extraSmall);
|
||||||
|
--td-font-size-s: var(--td-font-size-body-small);
|
||||||
|
--td-font-size-base: var(--td-font-size-title-small);
|
||||||
|
--td-font-size-m: var(--td-font-size-title-medium);
|
||||||
|
--td-font-size-l: var(--td-font-size-title-large);
|
||||||
|
--td-font-size-xl: var(--td-font-size-title-extraLarge);
|
||||||
|
--td-font-size-xxl: var(--td-font-size-headline-large);
|
||||||
|
--td-radius-small: 4px;
|
||||||
|
--td-radius-default: 6px;
|
||||||
|
--td-radius-large: 18px;
|
||||||
|
--td-radius-extraLarge: 24px;
|
||||||
|
--td-radius-round: 999px;
|
||||||
|
--td-radius-circle: 50%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Attachment, Model, User } from "timi-web";
|
||||||
|
|
||||||
|
export type CustomerGender = "MALE" | "FEMALE";
|
||||||
|
|
||||||
|
export type BodyAreaValue = {
|
||||||
|
selectedCodeList: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Customer = {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
gender?: CustomerGender;
|
||||||
|
age?: number;
|
||||||
|
telephone?: string;
|
||||||
|
address?: string;
|
||||||
|
remark?: string;
|
||||||
|
disease?: string;
|
||||||
|
conditioningPart?: BodyAreaValue;
|
||||||
|
introducerCustomerId?: string;
|
||||||
|
creatorUserId?: string;
|
||||||
|
lastSignInAt?: number;
|
||||||
|
introducerCustomer?: Customer;
|
||||||
|
creatorUser?: Partial<User>;
|
||||||
|
invitedCount?: number;
|
||||||
|
attachmentList?: Attachment[];
|
||||||
|
attachmentIdList?: string[];
|
||||||
|
} & Model;
|
||||||
|
|
||||||
|
export type CustomerSearchReq = {
|
||||||
|
keyword?: string;
|
||||||
|
introducerCustomerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomerTrendStatReq = {
|
||||||
|
beginCreatedAt: number;
|
||||||
|
endCreatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomerDailyStatView = {
|
||||||
|
dateValue?: number;
|
||||||
|
newCustomerCount?: number;
|
||||||
|
totalCustomerCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomerGenderStatView = {
|
||||||
|
genderCode?: CustomerGender | "UNKNOWN";
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomerInviteUpdateReq = {
|
||||||
|
customerId: string;
|
||||||
|
introducerCustomerId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuickRegisterReq = {
|
||||||
|
customerCode: string;
|
||||||
|
registrationEventId: string;
|
||||||
|
remark?: string;
|
||||||
|
attachmentIdList?: string[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Model, Role, User } from "timi-web";
|
||||||
|
|
||||||
|
export type GaoUser = {
|
||||||
|
userId?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
remark?: string;
|
||||||
|
user?: Partial<User>;
|
||||||
|
roleList?: Array<Role | string>;
|
||||||
|
roleIdList?: string[];
|
||||||
|
} & Model;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Model } from "timi-web";
|
||||||
|
|
||||||
|
export type RegistrationEventStatus = "ACTIVE" | "INACTIVE" | "DELETED";
|
||||||
|
|
||||||
|
export type RegistrationLimitType = "NONE" | "DAILY_HALF_DAY" | "NATURAL_DAY" | "INTERVAL_HOUR" | "NATURAL_MONTH" | "TOTAL_LIMIT";
|
||||||
|
|
||||||
|
export type RegistrationEvent = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
status?: RegistrationEventStatus;
|
||||||
|
enabled?: boolean;
|
||||||
|
requirePhoto?: boolean;
|
||||||
|
limitType?: RegistrationLimitType;
|
||||||
|
limitValue?: number;
|
||||||
|
sort?: number;
|
||||||
|
roleCodeList?: string[];
|
||||||
|
} & Model;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { Attachment, Model, User } from "timi-web";
|
||||||
|
import type { Customer } from "@/types/Customer";
|
||||||
|
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||||
|
|
||||||
|
export type RegistrationEventRecord = {
|
||||||
|
customerId?: string;
|
||||||
|
registrationEventId?: string;
|
||||||
|
operatorUserId?: string;
|
||||||
|
remark?: string;
|
||||||
|
registeredAt?: number;
|
||||||
|
customer?: Customer;
|
||||||
|
registrationEvent?: RegistrationEvent;
|
||||||
|
operator?: Partial<User>;
|
||||||
|
attachmentList?: Array<Partial<Attachment>>;
|
||||||
|
attachmentIdList?: string[];
|
||||||
|
} & Model;
|
||||||
|
|
||||||
|
export type RegistrationEventRecordListItemView = {
|
||||||
|
id?: string;
|
||||||
|
customerId?: string;
|
||||||
|
customerName?: string;
|
||||||
|
customerPhotoAttachmentId?: string;
|
||||||
|
registrationEventId?: string;
|
||||||
|
registrationEventName?: string;
|
||||||
|
registeredAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationEventRecordRangeReq = {
|
||||||
|
customerId?: string;
|
||||||
|
registrationEventId?: string;
|
||||||
|
registrationEventIdList?: string[];
|
||||||
|
keyword?: string;
|
||||||
|
beginRegisteredAt: number;
|
||||||
|
endRegisteredAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationEventRecordStatReq = {
|
||||||
|
customerId?: string;
|
||||||
|
beginRegisteredAt: number;
|
||||||
|
endRegisteredAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationEventRecordTrendStatReq = {
|
||||||
|
customerId?: string;
|
||||||
|
registrationEventId: string;
|
||||||
|
beginRegisteredAt: number;
|
||||||
|
endRegisteredAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomerRegisterStatView = {
|
||||||
|
customerId?: string;
|
||||||
|
customerName?: string;
|
||||||
|
registrationEventId?: string;
|
||||||
|
registrationEventName?: string;
|
||||||
|
count?: number;
|
||||||
|
latestRegisteredAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationEventRecordDailyStatView = {
|
||||||
|
dateValue?: number;
|
||||||
|
dailyCount?: number;
|
||||||
|
totalCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationEventRecordCalendarView = {
|
||||||
|
id?: string;
|
||||||
|
customerId?: string;
|
||||||
|
customerCode?: string;
|
||||||
|
customerName?: string;
|
||||||
|
registrationEventId?: string;
|
||||||
|
registeredAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegistrationEventRecordCreateReq = {
|
||||||
|
customerId?: string;
|
||||||
|
customerCode?: string;
|
||||||
|
registrationEventId: string;
|
||||||
|
remark?: string;
|
||||||
|
registeredAt?: number;
|
||||||
|
attachmentIdList?: string[];
|
||||||
|
};
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
declare module "browser-image-compression" {
|
||||||
|
export interface ImageCompressionOptions {
|
||||||
|
alwaysKeepResolution?: boolean;
|
||||||
|
exifOrientation?: number;
|
||||||
|
fileType?: string;
|
||||||
|
initialQuality?: number;
|
||||||
|
libURL?: string;
|
||||||
|
maxIteration?: number;
|
||||||
|
maxSizeMB?: number;
|
||||||
|
maxWidthOrHeight?: number;
|
||||||
|
onProgress?: (progress: number) => void;
|
||||||
|
preserveExif?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
useWebWorker?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function imageCompression(file: File, options: ImageCompressionOptions): Promise<File>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { AttachmentAPI } from "timi-web";
|
||||||
|
import type { BodyAreaValue, Customer } from "@/types/Customer";
|
||||||
|
|
||||||
|
export function getCustomerPhotoURL(customer?: Customer): string {
|
||||||
|
const attachment = customer?.attachmentList?.find(item => item.attachType === "PHOTO") || customer?.attachmentList?.[0];
|
||||||
|
if (!attachment?.id) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttachmentAPI.getReadURL(attachment.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBodyAreaValue(): BodyAreaValue {
|
||||||
|
return {
|
||||||
|
selectedCodeList: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBodyAreaValue(value: unknown): BodyAreaValue {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return createBodyAreaValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = value as Partial<BodyAreaValue>;
|
||||||
|
if (!Array.isArray(raw.selectedCodeList)) {
|
||||||
|
return createBodyAreaValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedCodeList: raw.selectedCodeList.map(item => String(item).trim()).filter(Boolean)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
export const gaoPermissionCodes = {
|
||||||
|
customer: {
|
||||||
|
create: "GAO:CUSTOMER:CREATE",
|
||||||
|
read: "GAO:CUSTOMER:READ",
|
||||||
|
update: "GAO:CUSTOMER:UPDATE",
|
||||||
|
delete: "GAO:CUSTOMER:DELETE"
|
||||||
|
},
|
||||||
|
registrationEvent: {
|
||||||
|
create: "GAO:REGISTRATION_EVENT:CREATE",
|
||||||
|
read: "GAO:REGISTRATION_EVENT:READ",
|
||||||
|
update: "GAO:REGISTRATION_EVENT:UPDATE",
|
||||||
|
delete: "GAO:REGISTRATION_EVENT:DELETE"
|
||||||
|
},
|
||||||
|
registrationEventRecord: {
|
||||||
|
create: "GAO:REGISTRATION_EVENT_RECORD:CREATE",
|
||||||
|
read: "GAO:REGISTRATION_EVENT_RECORD:READ",
|
||||||
|
update: "GAO:REGISTRATION_EVENT_RECORD:UPDATE",
|
||||||
|
delete: "GAO:REGISTRATION_EVENT_RECORD:DELETE"
|
||||||
|
},
|
||||||
|
stat: {
|
||||||
|
read: "GAO:STAT:READ"
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
create: "GAO:USER:CREATE",
|
||||||
|
read: "GAO:USER:READ",
|
||||||
|
update: "GAO:USER:UPDATE",
|
||||||
|
delete: "GAO:USER:DELETE"
|
||||||
|
},
|
||||||
|
qrCode: {
|
||||||
|
create: "GAO:QR_CODE:CREATE"
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const corePermissionCodes = {
|
||||||
|
role: {
|
||||||
|
create: "CORE:ROLE:CREATE",
|
||||||
|
read: "CORE:ROLE:READ",
|
||||||
|
update: "CORE:ROLE:UPDATE",
|
||||||
|
delete: "CORE:ROLE:DELETE"
|
||||||
|
},
|
||||||
|
permission: {
|
||||||
|
create: "CORE:PERMISSION:CREATE",
|
||||||
|
read: "CORE:PERMISSION:READ",
|
||||||
|
update: "CORE:PERMISSION:UPDATE",
|
||||||
|
delete: "CORE:PERMISSION:DELETE"
|
||||||
|
},
|
||||||
|
rolePermission: {
|
||||||
|
create: "CORE:ROLE_PERMISSION:CREATE",
|
||||||
|
read: "CORE:ROLE_PERMISSION:READ",
|
||||||
|
delete: "CORE:ROLE_PERMISSION:DELETE"
|
||||||
|
},
|
||||||
|
roleRelation: {
|
||||||
|
create: "CORE:ROLE_RELATION:CREATE",
|
||||||
|
read: "CORE:ROLE_RELATION:READ",
|
||||||
|
delete: "CORE:ROLE_RELATION:DELETE"
|
||||||
|
},
|
||||||
|
userRole: {
|
||||||
|
create: "CORE:USER_ROLE:CREATE",
|
||||||
|
read: "CORE:USER_ROLE:READ",
|
||||||
|
delete: "CORE:USER_ROLE:DELETE"
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const coreRoleCodes = {
|
||||||
|
admin: "CORE:ADMIN",
|
||||||
|
userRoleAdmin: "CORE:USER_ROLE_ADMIN"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const permissionManagementRoleAccess = {
|
||||||
|
value: [
|
||||||
|
coreRoleCodes.admin,
|
||||||
|
coreRoleCodes.userRoleAdmin
|
||||||
|
],
|
||||||
|
mode: "or"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type GaoPermissionCodes = typeof gaoPermissionCodes;
|
||||||
|
export type CorePermissionCodes = typeof corePermissionCodes;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Role } from "@/store/permissionSettingsStore";
|
||||||
|
import type { GaoUser } from "@/types/GaoUser";
|
||||||
|
|
||||||
|
type GaoUserRole = NonNullable<GaoUser["roleList"]>[number];
|
||||||
|
|
||||||
|
function buildRoleCode(role: Role): string {
|
||||||
|
return role.moduleCode && !role.code.includes(":") ? `${role.moduleCode}:${role.code}` : role.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBareRoleCode(code: string): string {
|
||||||
|
const codePartList = code.split(":");
|
||||||
|
return codePartList[codePartList.length - 1] || code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRole(role: GaoUserRole, roleList: Role[]): Role | undefined {
|
||||||
|
if (typeof role === "string") {
|
||||||
|
return roleList.find(roleItem => roleItem.code === role || buildRoleCode(roleItem) === role);
|
||||||
|
}
|
||||||
|
return roleList.find(roleItem => roleItem.id === role.id || roleItem.code === role.code || buildRoleCode(roleItem) === role.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCompatibleRoleCode(roleCodeSet: Set<string>, code: string): void {
|
||||||
|
if (!code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
roleCodeSet.add(code);
|
||||||
|
roleCodeSet.add(getBareRoleCode(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGaoRoleName(role: GaoUserRole | undefined, roleList: Role[]): string {
|
||||||
|
if (!role) {
|
||||||
|
return "未配置角色";
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullRole = findRole(role, roleList);
|
||||||
|
if (fullRole) {
|
||||||
|
return fullRole.name || fullRole.code || "未配置角色";
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof role === "string" ? getBareRoleCode(role) : role.name || role.code || "未配置角色";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEffectiveRoleCodeSet(user: GaoUser, roleList: Role[]): Set<string> {
|
||||||
|
const roleCodeSet = new Set<string>();
|
||||||
|
for (const role of user.roleList || []) {
|
||||||
|
const fullRole = findRole(role, roleList);
|
||||||
|
if (fullRole) {
|
||||||
|
appendRoleCode(roleCodeSet, fullRole, roleList, new Set<string>());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
appendCompatibleRoleCode(roleCodeSet, typeof role === "string" ? role : role.code);
|
||||||
|
}
|
||||||
|
return roleCodeSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRoleCode(roleCodeSet: Set<string>, role: Role, roleList: Role[], visitedRoleIds: Set<string>): void {
|
||||||
|
if (!role.id || visitedRoleIds.has(role.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visitedRoleIds.add(role.id);
|
||||||
|
appendCompatibleRoleCode(roleCodeSet, buildRoleCode(role));
|
||||||
|
for (const childRoleId of role.childRoleIdList) {
|
||||||
|
const childRole = roleList.find(item => item.id === childRoleId);
|
||||||
|
if (childRole) {
|
||||||
|
appendRoleCode(roleCodeSet, childRole, roleList, visitedRoleIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import imageCompression from "browser-image-compression";
|
||||||
|
|
||||||
|
const PHOTO_MAX_SIZE_MB = 300 / 1024;
|
||||||
|
const PHOTO_MAX_WIDTH_OR_HEIGHT = 1920;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压缩上传图片,统一限制到 300 KB。
|
||||||
|
*/
|
||||||
|
export async function compressUploadImage(file: File): Promise<File> {
|
||||||
|
return await imageCompression(file, {
|
||||||
|
maxSizeMB: PHOTO_MAX_SIZE_MB,
|
||||||
|
maxWidthOrHeight: PHOTO_MAX_WIDTH_OR_HEIGHT,
|
||||||
|
useWebWorker: true,
|
||||||
|
initialQuality: .86
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量压缩上传图片。
|
||||||
|
*/
|
||||||
|
export async function compressUploadImageList(fileList: File[]): Promise<File[]> {
|
||||||
|
const result: File[] = [];
|
||||||
|
for (const file of fileList) {
|
||||||
|
result.push(await compressUploadImage(file));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user