fix: add excel import support for groups, permissions, and roles (#4585)

This commit is contained in:
Yang Luo
2025-12-07 22:24:12 +08:00
parent 8b30e12915
commit 49c417c70e
9 changed files with 379 additions and 122 deletions

View File

@@ -15,6 +15,9 @@
package object
import (
"fmt"
"strings"
"github.com/casdoor/casdoor/xlsx"
)
@@ -36,44 +39,30 @@ func getPermissionMap(owner string) (map[string]*Permission, error) {
func UploadPermissions(owner string, path string) (bool, error) {
table := xlsx.ReadXlsxFile(path)
oldUserMap, err := getPermissionMap(owner)
if len(table) == 0 {
return false, fmt.Errorf("empty table")
}
for idx, row := range table[0] {
splitRow := strings.Split(row, "#")
if len(splitRow) > 1 {
table[0][idx] = splitRow[1]
}
}
uploadedPermissions, err := StringArrayToStruct[Permission](table)
if err != nil {
return false, err
}
oldPermissionMap, err := getPermissionMap(owner)
if err != nil {
return false, err
}
newPermissions := []*Permission{}
for index, line := range table {
if index == 0 || parseLineItem(&line, 0) == "" {
continue
}
permission := &Permission{
Owner: parseLineItem(&line, 0),
Name: parseLineItem(&line, 1),
CreatedTime: parseLineItem(&line, 2),
DisplayName: parseLineItem(&line, 3),
Users: parseListItem(&line, 4),
Roles: parseListItem(&line, 5),
Domains: parseListItem(&line, 6),
Model: parseLineItem(&line, 7),
Adapter: parseLineItem(&line, 8),
ResourceType: parseLineItem(&line, 9),
Resources: parseListItem(&line, 10),
Actions: parseListItem(&line, 11),
Effect: parseLineItem(&line, 12),
IsEnabled: parseLineItemBool(&line, 13),
Submitter: parseLineItem(&line, 14),
Approver: parseLineItem(&line, 15),
ApproveTime: parseLineItem(&line, 16),
State: parseLineItem(&line, 17),
}
if _, ok := oldUserMap[permission.GetId()]; !ok {
for _, permission := range uploadedPermissions {
if _, ok := oldPermissionMap[permission.GetId()]; !ok {
newPermissions = append(newPermissions, permission)
}
}

View File

@@ -15,6 +15,9 @@
package object
import (
"fmt"
"strings"
"github.com/casdoor/casdoor/xlsx"
)
@@ -36,30 +39,30 @@ func getRoleMap(owner string) (map[string]*Role, error) {
func UploadRoles(owner string, path string) (bool, error) {
table := xlsx.ReadXlsxFile(path)
oldUserMap, err := getRoleMap(owner)
if len(table) == 0 {
return false, fmt.Errorf("empty table")
}
for idx, row := range table[0] {
splitRow := strings.Split(row, "#")
if len(splitRow) > 1 {
table[0][idx] = splitRow[1]
}
}
uploadedRoles, err := StringArrayToStruct[Role](table)
if err != nil {
return false, err
}
oldRoleMap, err := getRoleMap(owner)
if err != nil {
return false, err
}
newRoles := []*Role{}
for index, line := range table {
if index == 0 || parseLineItem(&line, 0) == "" {
continue
}
role := &Role{
Owner: parseLineItem(&line, 0),
Name: parseLineItem(&line, 1),
CreatedTime: parseLineItem(&line, 2),
DisplayName: parseLineItem(&line, 3),
Users: parseListItem(&line, 4),
Roles: parseListItem(&line, 5),
Domains: parseListItem(&line, 6),
IsEnabled: parseLineItemBool(&line, 7),
}
if _, ok := oldUserMap[role.GetId()]; !ok {
for _, role := range uploadedRoles {
if _, ok := oldRoleMap[role.GetId()]; !ok {
newRoles = append(newRoles, role)
}
}

View File

@@ -14,7 +14,7 @@
import React from "react";
import {Link} from "react-router-dom";
import {Button, Table, Tooltip, Upload} from "antd";
import {Button, Modal, Table, Tooltip, Upload} from "antd";
import {UploadOutlined} from "@ant-design/icons";
import moment from "moment";
import * as Setting from "./Setting";
@@ -22,6 +22,7 @@ import * as GroupBackend from "./backend/GroupBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
import * as XLSX from "xlsx";
class GroupListPage extends BaseListPage {
constructor(props) {
@@ -89,38 +90,106 @@ class GroupListPage extends BaseListPage {
}
uploadFile(info) {
const {status, response: res} = info.file;
if (status === "done") {
if (res.status === "ok") {
Setting.showMessage("success", "Groups uploaded successfully, refreshing the page");
const {pagination} = this.state;
this.fetch({pagination});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${res.msg}`);
}
const {status, msg} = info;
if (status === "ok") {
Setting.showMessage("success", "Groups uploaded successfully, refreshing the page");
const {pagination} = this.state;
this.fetch({pagination});
} else if (status === "error") {
Setting.showMessage("error", i18next.t("general:Failed to upload"));
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${msg}`);
}
this.setState({uploadJsonData: [], uploadColumns: [], showUploadModal: false});
}
generateDownloadTemplate() {
const groupObj = {};
const items = Setting.getGroupColumns();
items.forEach((item) => {
groupObj[item] = null;
});
const worksheet = XLSX.utils.json_to_sheet([groupObj]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
XLSX.writeFile(workbook, "import-group.xlsx", {compression: true});
}
renderUpload() {
const uploadThis = this;
const props = {
name: "file",
accept: ".xlsx",
method: "post",
action: `${Setting.ServerUrl}/api/upload-groups`,
withCredentials: true,
onChange: (info) => {
this.uploadFile(info);
showUploadList: false,
beforeUpload: (file) => {
const reader = new FileReader();
reader.onload = (e) => {
const binary = e.target.result;
try {
const workbook = XLSX.read(binary, {type: "array"});
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
Setting.showMessage("error", i18next.t("general:No sheets found in file"));
return;
}
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
this.setState({uploadJsonData: jsonData, file: file});
const columns = Setting.getGroupColumns().map(el => {
return {title: el.split("#")[0], dataIndex: el, key: el};
});
this.setState({uploadColumns: columns}, () => {this.setState({showUploadModal: true});});
} catch (err) {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${err.message}`);
}
};
reader.onerror = (error) => {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${error?.message || error}`);
};
reader.readAsArrayBuffer(file);
return false;
},
};
return (
<Upload {...props}>
<Button icon={<UploadOutlined />} id="upload-button" size="small">
{i18next.t("group:Upload (.xlsx)")}
</Button>
</Upload>
<>
<Upload {...props}>
<Button icon={<UploadOutlined />} id="upload-button" size="small">
{i18next.t("general:Upload (.xlsx)")}
</Button>
</Upload>
<Modal title={i18next.t("general:Upload (.xlsx)")}
width={"100%"}
closable={true}
open={this.state.showUploadModal}
okText={i18next.t("general:Click to Upload")}
onOk = {() => {
const formData = new FormData();
formData.append("file", this.state.file);
fetch(`${Setting.ServerUrl}/api/upload-groups`, {
method: "post",
body: formData,
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
})
.then((res) => res.json())
.then((res) => {uploadThis.uploadFile(res);})
.catch((error) => {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${error.message}`);
});
}}
cancelText={i18next.t("general:Cancel")}
onCancel={() => {this.setState({showUploadModal: false, uploadJsonData: [], uploadColumns: []});}}
>
<div style={{marginRight: "34px"}}>
<Table scroll={{x: "max-content"}} dataSource={this.state.uploadJsonData} columns={this.state.uploadColumns} />
</div>
</Modal>
</>
);
}
@@ -269,6 +338,7 @@ class GroupListPage extends BaseListPage {
<div>
{i18next.t("general:Groups")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button style={{marginRight: "15px"}} type="primary" size="small" onClick={this.addGroup.bind(this)}>{i18next.t("general:Add")}</Button>
<Button style={{marginRight: "15px"}} type="primary" size="small" onClick={this.generateDownloadTemplate}>{i18next.t("general:Download template")} </Button>
{
this.renderUpload()
}

View File

@@ -14,7 +14,7 @@
import React from "react";
import {Link} from "react-router-dom";
import {Button, Switch, Table, Upload} from "antd";
import {Button, Modal, Switch, Table, Upload} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as PermissionBackend from "./backend/PermissionBackend";
@@ -22,6 +22,7 @@ import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
import {UploadOutlined} from "@ant-design/icons";
import * as XLSX from "xlsx";
class PermissionListPage extends BaseListPage {
newPermission() {
@@ -85,38 +86,106 @@ class PermissionListPage extends BaseListPage {
}
uploadPermissionFile(info) {
const {status, response: res} = info.file;
if (status === "done") {
if (res.status === "ok") {
Setting.showMessage("success", "Users uploaded successfully, refreshing the page");
const {pagination} = this.state;
this.fetch({pagination});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to sync")}: ${res.msg}`);
}
const {status, msg} = info;
if (status === "ok") {
Setting.showMessage("success", "Permissions uploaded successfully, refreshing the page");
const {pagination} = this.state;
this.fetch({pagination});
} else if (status === "error") {
Setting.showMessage("error", i18next.t("general:Failed to upload"));
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${msg}`);
}
this.setState({uploadJsonData: [], uploadColumns: [], showUploadModal: false});
}
generateDownloadTemplate() {
const permissionObj = {};
const items = Setting.getPermissionColumns();
items.forEach((item) => {
permissionObj[item] = null;
});
const worksheet = XLSX.utils.json_to_sheet([permissionObj]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
XLSX.writeFile(workbook, "import-permission.xlsx", {compression: true});
}
renderPermissionUpload() {
const uploadThis = this;
const props = {
name: "file",
accept: ".xlsx",
method: "post",
action: `${Setting.ServerUrl}/api/upload-permissions`,
withCredentials: true,
onChange: (info) => {
this.uploadPermissionFile(info);
showUploadList: false,
beforeUpload: (file) => {
const reader = new FileReader();
reader.onload = (e) => {
const binary = e.target.result;
try {
const workbook = XLSX.read(binary, {type: "array"});
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
Setting.showMessage("error", i18next.t("general:No sheets found in file"));
return;
}
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
this.setState({uploadJsonData: jsonData, file: file});
const columns = Setting.getPermissionColumns().map(el => {
return {title: el.split("#")[0], dataIndex: el, key: el};
});
this.setState({uploadColumns: columns}, () => {this.setState({showUploadModal: true});});
} catch (err) {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${err.message}`);
}
};
reader.onerror = (error) => {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${error?.message || error}`);
};
reader.readAsArrayBuffer(file);
return false;
},
};
return (
<Upload {...props}>
<Button icon={<UploadOutlined />} id="upload-button" size="small">
{i18next.t("user:Upload (.xlsx)")}
</Button></Upload>
<>
<Upload {...props}>
<Button icon={<UploadOutlined />} id="upload-button" size="small">
{i18next.t("general:Upload (.xlsx)")}
</Button>
</Upload>
<Modal title={i18next.t("general:Upload (.xlsx)")}
width={"100%"}
closable={true}
open={this.state.showUploadModal}
okText={i18next.t("general:Click to Upload")}
onOk = {() => {
const formData = new FormData();
formData.append("file", this.state.file);
fetch(`${Setting.ServerUrl}/api/upload-permissions`, {
method: "post",
body: formData,
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
})
.then((res) => res.json())
.then((res) => {uploadThis.uploadPermissionFile(res);})
.catch((error) => {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${error.message}`);
});
}}
cancelText={i18next.t("general:Cancel")}
onCancel={() => {this.setState({showUploadModal: false, uploadJsonData: [], uploadColumns: []});}}
>
<div style={{marginRight: "34px"}}>
<Table scroll={{x: "max-content"}} dataSource={this.state.uploadJsonData} columns={this.state.uploadColumns} />
</div>
</Modal>
</>
);
}
@@ -408,6 +477,7 @@ class PermissionListPage extends BaseListPage {
<div>
{i18next.t("general:Permissions")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button id="add-button" style={{marginRight: "15px"}} type="primary" size="small" onClick={this.addPermission.bind(this)}>{i18next.t("general:Add")}</Button>
<Button style={{marginRight: "15px"}} type="primary" size="small" onClick={this.generateDownloadTemplate}>{i18next.t("general:Download template")} </Button>
{
this.renderPermissionUpload()
}

View File

@@ -14,7 +14,7 @@
import React from "react";
import {Link} from "react-router-dom";
import {Button, Switch, Table, Upload} from "antd";
import {Button, Modal, Switch, Table, Upload} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as RoleBackend from "./backend/RoleBackend";
@@ -22,6 +22,7 @@ import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
import {UploadOutlined} from "@ant-design/icons";
import * as XLSX from "xlsx";
class RoleListPage extends BaseListPage {
newRole() {
@@ -77,39 +78,106 @@ class RoleListPage extends BaseListPage {
}
uploadRoleFile(info) {
const {status, response: res} = info.file;
if (status === "done") {
if (res.status === "ok") {
Setting.showMessage("success", "Users uploaded successfully, refreshing the page");
const {pagination} = this.state;
this.fetch({pagination});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to sync")}: ${res.msg}`);
}
const {status, msg} = info;
if (status === "ok") {
Setting.showMessage("success", "Roles uploaded successfully, refreshing the page");
const {pagination} = this.state;
this.fetch({pagination});
} else if (status === "error") {
Setting.showMessage("error", i18next.t("general:Failed to upload"));
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${msg}`);
}
this.setState({uploadJsonData: [], uploadColumns: [], showUploadModal: false});
}
generateDownloadTemplate() {
const roleObj = {};
const items = Setting.getRoleColumns();
items.forEach((item) => {
roleObj[item] = null;
});
const worksheet = XLSX.utils.json_to_sheet([roleObj]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
XLSX.writeFile(workbook, "import-role.xlsx", {compression: true});
}
renderRoleUpload() {
const uploadThis = this;
const props = {
name: "file",
accept: ".xlsx",
method: "post",
action: `${Setting.ServerUrl}/api/upload-roles`,
withCredentials: true,
onChange: (info) => {
this.uploadRoleFile(info);
showUploadList: false,
beforeUpload: (file) => {
const reader = new FileReader();
reader.onload = (e) => {
const binary = e.target.result;
try {
const workbook = XLSX.read(binary, {type: "array"});
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
Setting.showMessage("error", i18next.t("general:No sheets found in file"));
return;
}
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
this.setState({uploadJsonData: jsonData, file: file});
const columns = Setting.getRoleColumns().map(el => {
return {title: el.split("#")[0], dataIndex: el, key: el};
});
this.setState({uploadColumns: columns}, () => {this.setState({showUploadModal: true});});
} catch (err) {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${err.message}`);
}
};
reader.onerror = (error) => {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${error?.message || error}`);
};
reader.readAsArrayBuffer(file);
return false;
},
};
return (
<Upload {...props}>
<Button icon={<UploadOutlined />} size="small">
{i18next.t("user:Upload (.xlsx)")}
</Button>
</Upload>
<>
<Upload {...props}>
<Button icon={<UploadOutlined />} size="small">
{i18next.t("general:Upload (.xlsx)")}
</Button>
</Upload>
<Modal title={i18next.t("general:Upload (.xlsx)")}
width={"100%"}
closable={true}
open={this.state.showUploadModal}
okText={i18next.t("general:Click to Upload")}
onOk = {() => {
const formData = new FormData();
formData.append("file", this.state.file);
fetch(`${Setting.ServerUrl}/api/upload-roles`, {
method: "post",
body: formData,
credentials: "include",
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
})
.then((res) => res.json())
.then((res) => {uploadThis.uploadRoleFile(res);})
.catch((error) => {
Setting.showMessage("error", `${i18next.t("general:Failed to upload")}: ${error.message}`);
});
}}
cancelText={i18next.t("general:Cancel")}
onCancel={() => {this.setState({showUploadModal: false, uploadJsonData: [], uploadColumns: []});}}
>
<div style={{marginRight: "34px"}}>
<Table scroll={{x: "max-content"}} dataSource={this.state.uploadJsonData} columns={this.state.uploadColumns} />
</div>
</Modal>
</>
);
}
renderTable(roles) {
@@ -253,6 +321,7 @@ class RoleListPage extends BaseListPage {
<div>
{i18next.t("general:Roles")}&nbsp;&nbsp;&nbsp;&nbsp;
<Button style={{marginRight: "5px"}} type="primary" size="small" onClick={this.addRole.bind(this)}>{i18next.t("general:Add")}</Button>
<Button style={{marginRight: "5px"}} type="primary" size="small" onClick={this.generateDownloadTemplate}>{i18next.t("general:Download template")} </Button>
{
this.renderRoleUpload()
}

View File

@@ -471,6 +471,16 @@ export const UserFields = ["owner", "name", "password", "display_name", "id", "t
"created_time", "updated_time", "deleted_time",
"ip_whitelist"];
export const GroupFields = ["owner", "name", "created_time", "updated_time", "display_name", "manager",
"contact_email", "type", "parent_id", "is_top_group", "is_enabled"];
export const RoleFields = ["owner", "name", "created_time", "display_name", "description",
"users", "groups", "roles", "domains", "is_enabled"];
export const PermissionFields = ["owner", "name", "created_time", "display_name", "description",
"users", "groups", "roles", "domains", "model", "adapter", "resource_type",
"resources", "actions", "effect", "is_enabled", "submitter", "approver", "approve_time", "state"];
export const GetTranslatedUserItems = () => {
return [
{name: "Organization", label: i18next.t("general:Organization")},
@@ -565,6 +575,54 @@ export function getUserColumns() {
});
}
export function getGroupColumns() {
return GroupFields.map(field => {
let transField = field.toLowerCase().split("_").join(" ");
transField = transField.charAt(0).toUpperCase() + transField.slice(1);
transField = transField.replace("Id", "ID");
if (transField === "Owner") {
transField = "Organization";
}
const toTranslateList = ["general", "group"].map(ns => `${ns}:${transField}`);
const transResult = toTranslateList.map(item => i18next.t(item) === transField ? null : i18next.t(item))
.find(item => item !== null);
transField = transResult ? transResult : transField;
return `${transField}#${field}`;
});
}
export function getRoleColumns() {
return RoleFields.map(field => {
let transField = field.toLowerCase().split("_").join(" ");
transField = transField.charAt(0).toUpperCase() + transField.slice(1);
transField = transField.replace("Id", "ID");
if (transField === "Owner") {
transField = "Organization";
}
const toTranslateList = ["general", "role"].map(ns => `${ns}:${transField}`);
const transResult = toTranslateList.map(item => i18next.t(item) === transField ? null : i18next.t(item))
.find(item => item !== null);
transField = transResult ? transResult : transField;
return `${transField}#${field}`;
});
}
export function getPermissionColumns() {
return PermissionFields.map(field => {
let transField = field.toLowerCase().split("_").join(" ");
transField = transField.charAt(0).toUpperCase() + transField.slice(1);
transField = transField.replace("Id", "ID");
if (transField === "Owner") {
transField = "Organization";
}
const toTranslateList = ["general", "permission"].map(ns => `${ns}:${transField}`);
const transResult = toTranslateList.map(item => i18next.t(item) === transField ? null : i18next.t(item))
.find(item => item !== null);
transField = transResult ? transResult : transField;
return `${transField}#${field}`;
});
}
export function initCountries() {
const countries = require("i18n-iso-countries");
countries.registerLocale(require("i18n-iso-countries/langs/" + getLanguage() + ".json"));

View File

@@ -230,10 +230,10 @@ class UserListPage extends BaseListPage {
<>
<Upload {...props}>
<Button icon={<UploadOutlined />} id="upload-button" size="small">
{i18next.t("user:Upload (.xlsx)")}
{i18next.t("general:Upload (.xlsx)")}
</Button>
</Upload>
<Modal title={i18next.t("user:Upload (.xlsx)")}
<Modal title={i18next.t("general:Upload (.xlsx)")}
width={"100%"}
closable={true}
open={this.state.showUploadModal}

View File

@@ -510,6 +510,7 @@
"Unknown authentication type": "Unknown authentication type",
"Up": "Up",
"Updated time": "Updated time",
"Upload (.xlsx)": "Upload (.xlsx)",
"User": "User",
"User - Tooltip": "Make sure the username is correct",
"User Management": "User Management",
@@ -535,7 +536,6 @@
"Parent group - Tooltip": "Parent group of this group",
"Physical": "Physical",
"Show all": "Show all",
"Upload (.xlsx)": "Upload (.xlsx)",
"Virtual": "Virtual",
"You need to delete all subgroups first. You can view the subgroups in the left group tree of the [Organizations] -> [Groups] page": "You need to delete all subgroups first. You can view the subgroups in the left group tree of the [Organizations] -> [Groups] page"
},
@@ -1381,7 +1381,6 @@
"Title - Tooltip": "Job title or position held in the affiliation",
"Two passwords you typed do not match.": "Two passwords you typed do not match.",
"Unlink": "Unlink",
"Upload (.xlsx)": "Upload (.xlsx)",
"Upload ID card back picture": "Upload ID card back picture",
"Upload ID card front picture": "Upload ID card front picture",
"Upload ID card with person picture": "Upload ID card with person picture",

View File

@@ -509,6 +509,7 @@
"Unknown authentication type": "未知的认证类型",
"Up": "上移",
"Updated time": "更新时间",
"Upload (.xlsx)": "上传 (.xlsx)",
"User": "用户",
"User - Tooltip": "请确保用户名正确",
"User Management": "用户管理",
@@ -534,7 +535,6 @@
"Parent group - Tooltip": "该组的父组",
"Physical": "实体组",
"Show all": "显示全部",
"Upload (.xlsx)": "上传(.xlsx)",
"Virtual": "虚拟组",
"You need to delete all subgroups first. You can view the subgroups in the left group tree of the [Organizations] -> [Groups] page": "您需要先删除所有子组。您可以在 [组织] -> [群组] 页面左侧的群组树中查看子组"
},
@@ -1368,7 +1368,6 @@
"Title - Tooltip": "在工作单位担任的职务",
"Two passwords you typed do not match.": "两次输入的密码不匹配。",
"Unlink": "解绑",
"Upload (.xlsx)": "上传(.xlsx",
"Upload ID card back picture": "上传身份证反面照片",
"Upload ID card front picture": "上传身份证正面照片",
"Upload ID card with person picture": "上传手持身份证照片",