Browse Source

✨ feat: add admin-only "remark" support for users

* backend
  - model: add `Remark` field (varchar 255, `json:"remark,omitempty"`); AutoMigrate handles schema change automatically
  - controller:
    * accept `remark` on user create/update endpoints
    * hide remark from regular users (`GetSelf`) by zero-ing the field before JSON marshalling
    * clarify inline comment explaining the omitempty behaviour

* frontend (React / Semi UI)
  - AddUser.js & EditUser.js: add “Remark” input for admins
  - UsersTable.js:
    * remove standalone “Remark” column
    * show remark as a truncated Tag next to username with Tooltip for full text
    * import Tooltip component
  - i18n: reuse existing translations where applicable

This commit enables administrators to label users with private notes while ensuring those notes are never exposed to the users themselves.
Apple\Apple 8 tháng trước cách đây
mục cha
commit
3ac02879de

+ 3 - 0
controller/user.go

@@ -459,6 +459,9 @@ func GetSelf(c *gin.Context) {
 		})
 		return
 	}
+	// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
+	user.Remark = ""
+
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",

+ 2 - 0
model/user.go

@@ -41,6 +41,7 @@ type User struct {
 	DeletedAt        gorm.DeletedAt `gorm:"index"`
 	LinuxDOId        string         `json:"linux_do_id" gorm:"column:linux_do_id;index"`
 	Setting          string         `json:"setting" gorm:"type:text;column:setting"`
+	Remark           string         `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
 }
 
 func (user *User) ToBaseUser() *UserBase {
@@ -366,6 +367,7 @@ func (user *User) Edit(updatePassword bool) error {
 		"display_name": newUser.DisplayName,
 		"group":        newUser.Group,
 		"quota":        newUser.Quota,
+		"remark":       newUser.Remark,
 	}
 	if updatePassword {
 		updates["password"] = newUser.Password

+ 22 - 0
web/src/components/table/UsersTable.js

@@ -26,6 +26,7 @@ import {
   Space,
   Table,
   Tag,
+  Tooltip,
   Typography
 } from '@douyinfe/semi-ui';
 import {
@@ -110,6 +111,27 @@ const UsersTable = () => {
     {
       title: t('用户名'),
       dataIndex: 'username',
+      render: (text, record) => {
+        const remark = record.remark;
+        if (!remark) {
+          return <span>{text}</span>;
+        }
+        const maxLen = 10;
+        const displayRemark = remark.length > maxLen ? remark.slice(0, maxLen) + '…' : remark;
+        return (
+          <Space spacing={2}>
+            <span>{text}</span>
+            <Tooltip content={remark} position="top" showArrow>
+              <Tag color='white' size='large' shape='circle' className="!text-xs">
+                <div className="flex items-center gap-1">
+                  <div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: '#10b981' }} />
+                  {displayRemark}
+                </div>
+              </Tag>
+            </Tooltip>
+          </Space>
+        );
+      },
     },
     {
       title: t('分组'),

+ 2 - 1
web/src/i18n/locales/en.json

@@ -1658,5 +1658,6 @@
   "清除失效兑换码": "Clear invalid redemption codes",
   "确定清除所有失效兑换码?": "Are you sure you want to clear all invalid redemption codes?",
   "将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "This will delete all used, disabled, and expired redemption codes, this operation cannot be undone.",
-  "选择过期时间(可选,留空为永久)": "Select expiration time (optional, leave blank for permanent)"
+  "选择过期时间(可选,留空为永久)": "Select expiration time (optional, leave blank for permanent)",
+  "请输入备注(仅管理员可见)": "Please enter a remark (only visible to administrators)"
 }

+ 17 - 1
web/src/pages/User/AddUser.js

@@ -16,6 +16,7 @@ import {
   IconClose,
   IconKey,
   IconUserAdd,
+  IconEdit,
 } from '@douyinfe/semi-icons';
 import { useTranslation } from 'react-i18next';
 
@@ -27,10 +28,11 @@ const AddUser = (props) => {
     username: '',
     display_name: '',
     password: '',
+    remark: '',
   };
   const [inputs, setInputs] = useState(originInputs);
   const [loading, setLoading] = useState(false);
-  const { username, display_name, password } = inputs;
+  const { username, display_name, password, remark } = inputs;
 
   const handleInputChange = (name, value) => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -175,6 +177,20 @@ const AddUser = (props) => {
                     required
                   />
                 </div>
+
+                <div>
+                  <Text strong className="block mb-2">{t('备注')}</Text>
+                  <Input
+                    placeholder={t('请输入备注(仅管理员可见)')}
+                    onChange={(value) => handleInputChange('remark', value)}
+                    value={remark}
+                    autoComplete="off"
+                    size="large"
+                    className="!rounded-lg"
+                    prefix={<IconEdit />}
+                    showClear
+                  />
+                </div>
               </div>
             </Card>
           </div>

+ 17 - 0
web/src/pages/User/EditUser.js

@@ -22,6 +22,7 @@ import {
   IconLink,
   IconUserGroup,
   IconPlus,
+  IconEdit,
 } from '@douyinfe/semi-icons';
 import { useTranslation } from 'react-i18next';
 
@@ -42,6 +43,7 @@ const EditUser = (props) => {
     email: '',
     quota: 0,
     group: 'default',
+    remark: '',
   });
   const [groupOptions, setGroupOptions] = useState([]);
   const {
@@ -55,6 +57,7 @@ const EditUser = (props) => {
     email,
     quota,
     group,
+    remark,
   } = inputs;
   const handleInputChange = (name, value) => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -247,6 +250,20 @@ const EditUser = (props) => {
                     showClear
                   />
                 </div>
+
+                <div>
+                  <Text strong className="block mb-2">{t('备注')}</Text>
+                  <Input
+                    placeholder={t('请输入备注(仅管理员可见)')}
+                    onChange={(value) => handleInputChange('remark', value)}
+                    value={remark}
+                    autoComplete="off"
+                    size="large"
+                    className="!rounded-lg"
+                    prefix={<IconEdit />}
+                    showClear
+                  />
+                </div>
               </div>
             </Card>