Просмотр исходного кода

feat: now user can check its topup & consume history (close #78, close #95)

JustSong 2 лет назад
Родитель
Сommit
74f508e847

+ 2 - 0
controller/relay.go

@@ -244,6 +244,8 @@ func relayHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode {
 			if err != nil {
 				common.SysError("Error consuming token remain quota: " + err.Error())
 			}
+			userId := c.GetInt("id")
+			model.RecordLog(userId, model.LogTypeConsume, fmt.Sprintf("使用模型 %s 消耗 %d 点额度", textRequest.Model, quota))
 		}
 	}()
 

+ 18 - 3
model/log.go

@@ -1,6 +1,9 @@
 package model
 
-import "one-api/common"
+import (
+	"gorm.io/gorm"
+	"one-api/common"
+)
 
 type Log struct {
 	Id        int    `json:"id"`
@@ -10,6 +13,12 @@ type Log struct {
 	Content   string `json:"content"`
 }
 
+const (
+	LogTypeUnknown = iota
+	LogTypeTopup
+	LogTypeConsume
+)
+
 func RecordLog(userId int, logType int, content string) {
 	log := &Log{
 		UserId:    userId,
@@ -29,7 +38,13 @@ func GetAllLogs(logType int, startIdx int, num int) (logs []*Log, err error) {
 }
 
 func GetUserLogs(userId int, logType int, startIdx int, num int) (logs []*Log, err error) {
-	err = DB.Where("user_id = ? and type = ?", userId, logType).Order("id desc").Limit(num).Offset(startIdx).Find(&logs).Error
+	var tx *gorm.DB
+	if logType == LogTypeUnknown {
+		tx = DB.Where("user_id = ?", userId)
+	} else {
+		tx = DB.Where("user_id = ? and type = ?", userId, logType)
+	}
+	err = tx.Order("id desc").Limit(num).Offset(startIdx).Omit("id").Find(&logs).Error
 	return logs, err
 }
 
@@ -39,6 +54,6 @@ func SearchAllLogs(keyword string) (logs []*Log, err error) {
 }
 
 func SearchUserLogs(userId int, keyword string) (logs []*Log, err error) {
-	err = DB.Where("user_id = ? and type = ?", userId, keyword).Order("id desc").Limit(common.MaxRecentItems).Find(&logs).Error
+	err = DB.Where("user_id = ? and type = ?", userId, keyword).Order("id desc").Limit(common.MaxRecentItems).Omit("id").Find(&logs).Error
 	return logs, err
 }

+ 4 - 0
model/main.go

@@ -79,6 +79,10 @@ func InitDB() (err error) {
 		if err != nil {
 			return err
 		}
+		err = db.AutoMigrate(&Log{})
+		if err != nil {
+			return err
+		}
 		err = createRootAccountIfNeed()
 		return err
 	} else {

+ 2 - 0
model/redemption.go

@@ -2,6 +2,7 @@ package model
 
 import (
 	"errors"
+	"fmt"
 	"one-api/common"
 )
 
@@ -65,6 +66,7 @@ func Redeem(key string, userId int) (quota int, err error) {
 		if err != nil {
 			common.SysError("更新兑换码状态失败:" + err.Error())
 		}
+		RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %d 点额度", redemption.Quota))
 	}()
 	return redemption.Quota, nil
 }

+ 9 - 0
web/src/App.js

@@ -22,6 +22,7 @@ import EditChannel from './pages/Channel/EditChannel';
 import Redemption from './pages/Redemption';
 import EditRedemption from './pages/Redemption/EditRedemption';
 import TopUp from './pages/TopUp';
+import Log from './pages/Log';
 
 const Home = lazy(() => import('./pages/Home'));
 const About = lazy(() => import('./pages/About'));
@@ -250,6 +251,14 @@ function App() {
         </PrivateRoute>
         }
       />
+      <Route
+        path='/log'
+        element={
+          <PrivateRoute>
+            <Log />
+          </PrivateRoute>
+        }
+      />
       <Route
         path='/about'
         element={

+ 5 - 0
web/src/components/Header.js

@@ -41,6 +41,11 @@ const headerButtons = [
     icon: 'user',
     admin: true,
   },
+  {
+    name: '日志',
+    to: '/log',
+    icon: 'book',
+  },
   {
     name: '设置',
     to: '/setting',

+ 186 - 0
web/src/components/LogsTable.js

@@ -0,0 +1,186 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Label, Pagination, Table } from 'semantic-ui-react';
+import { API, showError, timestamp2string } from '../helpers';
+
+import { ITEMS_PER_PAGE } from '../constants';
+
+function renderTimestamp(timestamp) {
+  return (
+    <>
+      {timestamp2string(timestamp)}
+    </>
+  );
+}
+
+function renderType(type) {
+  switch (type) {
+    case 1:
+      return <Label basic color='green'> 充值 </Label>;
+    case 2:
+      return <Label basic color='olive'> 消费 </Label>;
+    default:
+      return <Label basic color='black'> 未知 </Label>;
+  }
+}
+
+const LogsTable = () => {
+  const [logs, setLogs] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [searching, setSearching] = useState(false);
+
+  const loadLogs = async (startIdx) => {
+    const res = await API.get(`/api/log/self/?p=${startIdx}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      if (startIdx === 0) {
+        setLogs(data);
+      } else {
+        let newLogs = logs;
+        newLogs.push(...data);
+        setLogs(newLogs);
+      }
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const onPaginationChange = (e, { activePage }) => {
+    (async () => {
+      if (activePage === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
+        // In this case we have to load more data and then append them.
+        await loadLogs(activePage - 1);
+      }
+      setActivePage(activePage);
+    })();
+  };
+
+  const refresh = async () => {
+    setLoading(true);
+    await loadLogs(0);
+  };
+
+  useEffect(() => {
+    loadLogs(0)
+      .then()
+      .catch((reason) => {
+        showError(reason);
+      });
+  }, []);
+
+  const searchLogs = async () => {
+    if (searchKeyword === '') {
+      // if keyword is blank, load files instead.
+      await loadLogs(0);
+      setActivePage(1);
+      return;
+    }
+    setSearching(true);
+    const res = await API.get(`/api/log/self/search?keyword=${searchKeyword}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      setLogs(data);
+      setActivePage(1);
+    } else {
+      showError(message);
+    }
+    setSearching(false);
+  };
+
+  const handleKeywordChange = async (e, { value }) => {
+    setSearchKeyword(value.trim());
+  };
+
+  const sortLog = (key) => {
+    if (logs.length === 0) return;
+    setLoading(true);
+    let sortedLogs = [...logs];
+    sortedLogs.sort((a, b) => {
+      return ('' + a[key]).localeCompare(b[key]);
+    });
+    if (sortedLogs[0].id === logs[0].id) {
+      sortedLogs.reverse();
+    }
+    setLogs(sortedLogs);
+    setLoading(false);
+  };
+
+  return (
+    <>
+      <Table basic>
+        <Table.Header>
+          <Table.Row>
+            <Table.HeaderCell
+              style={{ cursor: 'pointer' }}
+              onClick={() => {
+                sortLog('created_time');
+              }}
+              width={3}
+            >
+              时间
+            </Table.HeaderCell>
+            <Table.HeaderCell
+              style={{ cursor: 'pointer' }}
+              onClick={() => {
+                sortLog('type');
+              }}
+              width={2}
+            >
+              类型
+            </Table.HeaderCell>
+            <Table.HeaderCell
+              style={{ cursor: 'pointer' }}
+              onClick={() => {
+                sortLog('content');
+              }}
+              width={11}
+            >
+              详情
+            </Table.HeaderCell>
+          </Table.Row>
+        </Table.Header>
+
+        <Table.Body>
+          {logs
+            .slice(
+              (activePage - 1) * ITEMS_PER_PAGE,
+              activePage * ITEMS_PER_PAGE
+            )
+            .map((log, idx) => {
+              if (log.deleted) return <></>;
+              return (
+                <Table.Row key={log.created_at}>
+                  <Table.Cell>{renderTimestamp(log.created_at)}</Table.Cell>
+                  <Table.Cell>{renderType(log.type)}</Table.Cell>
+                  <Table.Cell>{log.content}</Table.Cell>
+                </Table.Row>
+              );
+            })}
+        </Table.Body>
+
+        <Table.Footer>
+          <Table.Row>
+            <Table.HeaderCell colSpan='4'>
+              <Button size='small' onClick={refresh} loading={loading}>刷新</Button>
+              <Pagination
+                floated='right'
+                activePage={activePage}
+                onPageChange={onPaginationChange}
+                size='small'
+                siblingRange={1}
+                totalPages={
+                  Math.ceil(logs.length / ITEMS_PER_PAGE) +
+                  (logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
+                }
+              />
+            </Table.HeaderCell>
+          </Table.Row>
+        </Table.Footer>
+      </Table>
+    </>
+  );
+};
+
+export default LogsTable;

+ 14 - 0
web/src/pages/Log/index.js

@@ -0,0 +1,14 @@
+import React from 'react';
+import { Header, Segment } from 'semantic-ui-react';
+import LogsTable from '../../components/LogsTable';
+
+const Token = () => (
+  <>
+    <Segment>
+      <Header as='h3'>额度明细</Header>
+      <LogsTable />
+    </Segment>
+  </>
+);
+
+export default Token;