0327 添加数据权限

main
kangwenjing 2026-03-27 12:22:00 +08:00
parent f0631c38b3
commit 2b0e477e39
73 changed files with 1328 additions and 190 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -4,7 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323" /> <list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323">
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" /> <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
@ -24,29 +29,29 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"RequestMappingsPanelOrder0": "0", &quot;RequestMappingsPanelOrder0&quot;: &quot;0&quot;,
"RequestMappingsPanelOrder1": "1", &quot;RequestMappingsPanelOrder1&quot;: &quot;1&quot;,
"RequestMappingsPanelWidth0": "75", &quot;RequestMappingsPanelWidth0&quot;: &quot;75&quot;,
"RequestMappingsPanelWidth1": "75", &quot;RequestMappingsPanelWidth1&quot;: &quot;75&quot;,
"RunOnceActivity.OpenProjectViewOnStart": "true", &quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"WebServerToolWindowFactoryState": "false", &quot;WebServerToolWindowFactoryState&quot;: &quot;false&quot;,
"git-widget-placeholder": "main", &quot;git-widget-placeholder&quot;: &quot;main&quot;,
"last_opened_file_path": "/Users/kangwenjing/Downloads/crm/unis_crm", &quot;last_opened_file_path&quot;: &quot;/Users/kangwenjing/Downloads/crm/unis_crm&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"project.structure.last.edited": "模块", &quot;project.structure.last.edited&quot;: &quot;模块&quot;,
"project.structure.proportion": "0.0", &quot;project.structure.proportion&quot;: &quot;0.0&quot;,
"project.structure.side.proportion": "0.0", &quot;project.structure.side.proportion&quot;: &quot;0.0&quot;,
"ts.external.directory.path": "/Users/kangwenjing/Downloads/crm/unis_crm/frontend/node_modules/typescript/lib", &quot;ts.external.directory.path&quot;: &quot;/Users/kangwenjing/Downloads/crm/unis_crm/frontend/node_modules/typescript/lib&quot;,
"vue.rearranger.settings.migration": "true" &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
} }
}]]></component> }</component>
<component name="RunManager" selected="Spring Boot.UnisCrmBackendApplication"> <component name="RunManager" selected="Spring Boot.UnisCrmBackendApplication">
<configuration name="unis-crm-backend中的所有" type="JUnit" factoryName="JUnit" temporary="true" nameIsGenerated="true"> <configuration name="unis-crm-backend中的所有" type="JUnit" factoryName="JUnit" temporary="true" nameIsGenerated="true">
<module name="unis-crm-backend" /> <module name="unis-crm-backend" />
@ -81,6 +86,7 @@
<workItem from="1774235389841" duration="216000" /> <workItem from="1774235389841" duration="216000" />
<workItem from="1774244004381" duration="10126000" /> <workItem from="1774244004381" duration="10126000" />
<workItem from="1774574410059" duration="200000" /> <workItem from="1774574410059" duration="200000" />
<workItem from="1774576185724" duration="1651000" />
</task> </task>
<task id="LOCAL-00001" summary="修改定位信息 0323"> <task id="LOCAL-00001" summary="修改定位信息 0323">
<option name="closed" value="true" /> <option name="closed" value="true" />

View File

@ -19,6 +19,12 @@ public class CrmGlobalExceptionHandler {
return ApiResponse.fail(ex.getMessage()); return ApiResponse.fail(ex.getMessage());
} }
@ExceptionHandler(com.unisbase.common.exception.BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Object> handleFrameworkBusinessException(com.unisbase.common.exception.BusinessException ex) {
return ApiResponse.fail(ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Object> handleValidationException(MethodArgumentNotValidException ex) { public ApiResponse<Object> handleValidationException(MethodArgumentNotValidException ex) {
@ -29,6 +35,12 @@ public class CrmGlobalExceptionHandler {
return ApiResponse.fail(message); return ApiResponse.fail(message);
} }
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Object> handleIllegalArgumentException(IllegalArgumentException ex) {
return ApiResponse.fail(ex.getMessage());
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map<String, Object> handleUnexpectedException(Exception ex, HttpServletRequest request) { public Map<String, Object> handleUnexpectedException(Exception ex, HttpServletRequest request) {

View File

@ -16,6 +16,7 @@ import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import com.unisbase.annotation.DataScope;
@Mapper @Mapper
public interface ExpansionMapper { public interface ExpansionMapper {
@ -24,18 +25,25 @@ public interface ExpansionMapper {
String selectNextChannelCode(); String selectNextChannelCode();
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
List<SalesExpansionItemDTO> selectSalesExpansions(@Param("userId") Long userId, @Param("keyword") String keyword); List<SalesExpansionItemDTO> selectSalesExpansions(@Param("userId") Long userId, @Param("keyword") String keyword);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
List<ChannelExpansionItemDTO> selectChannelExpansions(@Param("userId") Long userId, @Param("keyword") String keyword); List<ChannelExpansionItemDTO> selectChannelExpansions(@Param("userId") Long userId, @Param("keyword") String keyword);
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
List<ExpansionFollowUpDTO> selectSalesFollowUps(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds); List<ExpansionFollowUpDTO> selectSalesFollowUps(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<RelatedProjectSummaryDTO> selectSalesRelatedProjects(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds); List<RelatedProjectSummaryDTO> selectSalesRelatedProjects(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
List<ExpansionFollowUpDTO> selectChannelFollowUps(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds); List<ExpansionFollowUpDTO> selectChannelFollowUps(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
List<ChannelExpansionContactDTO> selectChannelContacts(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds); List<ChannelExpansionContactDTO> selectChannelContacts(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<ChannelRelatedProjectSummaryDTO> selectChannelRelatedProjects(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds); List<ChannelRelatedProjectSummaryDTO> selectChannelRelatedProjects(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);
int insertSalesExpansion(@Param("userId") Long userId, @Param("request") CreateSalesExpansionRequest request); int insertSalesExpansion(@Param("userId") Long userId, @Param("request") CreateSalesExpansionRequest request);

View File

@ -8,6 +8,7 @@ import com.unis.crm.dto.opportunity.OpportunityItemDTO;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import com.unisbase.annotation.DataScope;
@Mapper @Mapper
public interface OpportunityMapper { public interface OpportunityMapper {
@ -18,11 +19,13 @@ public interface OpportunityMapper {
@Param("typeCode") String typeCode, @Param("typeCode") String typeCode,
@Param("itemValue") String itemValue); @Param("itemValue") String itemValue);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<OpportunityItemDTO> selectOpportunities( List<OpportunityItemDTO> selectOpportunities(
@Param("userId") Long userId, @Param("userId") Long userId,
@Param("keyword") String keyword, @Param("keyword") String keyword,
@Param("stage") String stage); @Param("stage") String stage);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<OpportunityFollowUpDTO> selectOpportunityFollowUps( List<OpportunityFollowUpDTO> selectOpportunityFollowUps(
@Param("userId") Long userId, @Param("userId") Long userId,
@Param("opportunityIds") List<Long> opportunityIds); @Param("opportunityIds") List<Long> opportunityIds);

View File

@ -74,7 +74,7 @@
and industry_dict.item_value = s.industry and industry_dict.item_value = s.industry
and industry_dict.status = 1 and industry_dict.status = 1
and coalesce(industry_dict.is_deleted, 0) = 0 and coalesce(industry_dict.is_deleted, 0) = 0
where s.owner_user_id = #{userId} where 1 = 1
<if test="keyword != null and keyword != ''"> <if test="keyword != null and keyword != ''">
and ( and (
coalesce(s.employee_no, '') ilike concat('%', #{keyword}, '%') coalesce(s.employee_no, '') ilike concat('%', #{keyword}, '%')
@ -151,7 +151,7 @@
and internal_attribute_dict.item_value = c.internal_attribute and internal_attribute_dict.item_value = c.internal_attribute
and internal_attribute_dict.status = 1 and internal_attribute_dict.status = 1
and coalesce(internal_attribute_dict.is_deleted, 0) = 0 and coalesce(internal_attribute_dict.is_deleted, 0) = 0
where c.owner_user_id = #{userId} where 1 = 1
<if test="keyword != null and keyword != ''"> <if test="keyword != null and keyword != ''">
and ( and (
c.channel_name ilike concat('%', #{keyword}, '%') c.channel_name ilike concat('%', #{keyword}, '%')
@ -189,8 +189,7 @@
from crm_expansion_followup f from crm_expansion_followup f
join crm_sales_expansion s on s.id = f.biz_id and f.biz_type = 'sales' join crm_sales_expansion s on s.id = f.biz_id and f.biz_type = 'sales'
left join sys_user u on u.user_id = f.followup_user_id left join sys_user u on u.user_id = f.followup_user_id
where s.owner_user_id = #{userId} where f.biz_id in
and f.biz_id in
<foreach collection="bizIds" item="id" open="(" separator="," close=")"> <foreach collection="bizIds" item="id" open="(" separator="," close=")">
#{id} #{id}
</foreach> </foreach>
@ -205,8 +204,7 @@
o.opportunity_name as opportunityName, o.opportunity_name as opportunityName,
o.amount o.amount
from crm_opportunity o from crm_opportunity o
where o.owner_user_id = #{userId} where o.sales_expansion_id in
and o.sales_expansion_id in
<foreach collection="bizIds" item="id" open="(" separator="," close=")"> <foreach collection="bizIds" item="id" open="(" separator="," close=")">
#{id} #{id}
</foreach> </foreach>
@ -229,8 +227,7 @@
from crm_expansion_followup f from crm_expansion_followup f
join crm_channel_expansion c on c.id = f.biz_id and f.biz_type = 'channel' join crm_channel_expansion c on c.id = f.biz_id and f.biz_type = 'channel'
left join sys_user u on u.user_id = f.followup_user_id left join sys_user u on u.user_id = f.followup_user_id
where c.owner_user_id = #{userId} where f.biz_id in
and f.biz_id in
<foreach collection="bizIds" item="id" open="(" separator="," close=")"> <foreach collection="bizIds" item="id" open="(" separator="," close=")">
#{id} #{id}
</foreach> </foreach>
@ -246,8 +243,7 @@
coalesce(cc.contact_title, '无') as title coalesce(cc.contact_title, '无') as title
from crm_channel_expansion_contact cc from crm_channel_expansion_contact cc
join crm_channel_expansion c on c.id = cc.channel_expansion_id join crm_channel_expansion c on c.id = cc.channel_expansion_id
where c.owner_user_id = #{userId} where cc.channel_expansion_id in
and cc.channel_expansion_id in
<foreach collection="bizIds" item="id" open="(" separator="," close=")"> <foreach collection="bizIds" item="id" open="(" separator="," close=")">
#{id} #{id}
</foreach> </foreach>
@ -262,8 +258,7 @@
o.opportunity_name as opportunityName, o.opportunity_name as opportunityName,
o.amount o.amount
from crm_opportunity o from crm_opportunity o
where o.owner_user_id = #{userId} where o.channel_expansion_id in
and o.channel_expansion_id in
<foreach collection="bizIds" item="id" open="(" separator="," close=")"> <foreach collection="bizIds" item="id" open="(" separator="," close=")">
#{id} #{id}
</foreach> </foreach>

View File

@ -118,7 +118,7 @@
and operator_dict.item_value = o.operator_name and operator_dict.item_value = o.operator_name
and operator_dict.status = 1 and operator_dict.status = 1
and coalesce(operator_dict.is_deleted, 0) = 0 and coalesce(operator_dict.is_deleted, 0) = 0
where o.owner_user_id = #{userId} where 1 = 1
<if test="keyword != null and keyword != ''"> <if test="keyword != null and keyword != ''">
and ( and (
o.opportunity_name ilike concat('%', #{keyword}, '%') o.opportunity_name ilike concat('%', #{keyword}, '%')
@ -148,8 +148,7 @@
from crm_opportunity_followup f from crm_opportunity_followup f
join crm_opportunity o on o.id = f.opportunity_id join crm_opportunity o on o.id = f.opportunity_id
left join sys_user u on u.user_id = f.followup_user_id left join sys_user u on u.user_id = f.followup_user_id
where o.owner_user_id = #{userId} where f.opportunity_id in
and f.opportunity_id in
<foreach collection="opportunityIds" item="id" open="(" separator="," close=")"> <foreach collection="opportunityIds" item="id" open="(" separator="," close=")">
#{id} #{id}
</foreach> </foreach>

View File

@ -9,14 +9,20 @@
u.user_id as userId, u.user_id as userId,
u.display_name as realName, u.display_name as realName,
case case
when u.created_at is null then 0 when coalesce(org_info.joined_date, u.created_at::date) is null then 0
else greatest((current_date - u.created_at::date)::bigint, 0) else greatest((current_date - coalesce(org_info.joined_date, u.created_at::date))::bigint + 1, 0)
end as onboardingDays, end as onboardingDays,
case case
when u.status = 1 then '正常' when u.status = 1 then '正常'
else '停用' else '停用'
end as accountStatus end as accountStatus
from sys_user u from sys_user u
left join lateral (
select min(tu.created_at)::date as joined_date
from sys_tenant_user tu
where tu.user_id = u.user_id
and tu.is_deleted = 0
) org_info on true
where u.user_id = #{userId} where u.user_id = #{userId}
and u.is_deleted = 0 and u.is_deleted = 0
limit 1 limit 1

View File

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { BarChart3, Building2, Check, TrendingUp, Users } from "lucide-react"; import { BarChart3, Building2, Check, TrendingUp, Users } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useNavigate } from "react-router-dom";
import { completeDashboardTodo, getDashboardHome, type DashboardActivity, type DashboardHome, type DashboardStat, type DashboardTodo } from "@/lib/auth"; import { completeDashboardTodo, getDashboardHome, type DashboardActivity, type DashboardHome, type DashboardStat, type DashboardTodo } from "@/lib/auth";
const DASHBOARD_PREVIEW_COUNT = 5; const DASHBOARD_PREVIEW_COUNT = 5;
@ -13,7 +14,15 @@ const baseStats = [
{ name: "本月打卡次数", metricKey: "monthlyCheckins", icon: BarChart3, color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-100 dark:bg-amber-500/20" }, { name: "本月打卡次数", metricKey: "monthlyCheckins", icon: BarChart3, color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-100 dark:bg-amber-500/20" },
] as const; ] as const;
const statRoutes: Record<(typeof baseStats)[number]["metricKey"], { pathname: string; state?: { tab: "sales" | "channel" } }> = {
monthlyOpportunities: { pathname: "/opportunities" },
pushedOmsProjects: { pathname: "/opportunities" },
monthlyChannels: { pathname: "/expansion", state: { tab: "channel" } },
monthlyCheckins: { pathname: "/work/checkin" },
};
export default function Dashboard() { export default function Dashboard() {
const navigate = useNavigate();
const [home, setHome] = useState<DashboardHome>({}); const [home, setHome] = useState<DashboardHome>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showAllActivities, setShowAllActivities] = useState(false); const [showAllActivities, setShowAllActivities] = useState(false);
@ -97,6 +106,14 @@ export default function Dashboard() {
} }
}; };
const handleStatCardClick = (metricKey: (typeof baseStats)[number]["metricKey"]) => {
const target = statRoutes[metricKey];
if (!target) {
return;
}
navigate(target.pathname, target.state ? { state: target.state } : undefined);
};
return ( return (
<div className="crm-page-stack"> <div className="crm-page-stack">
<header className="space-y-1 sm:space-y-2"> <header className="space-y-1 sm:space-y-2">
@ -124,12 +141,16 @@ export default function Dashboard() {
> >
<div className="grid grid-cols-2 gap-x-3 gap-y-4 sm:gap-4 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-x-3 gap-y-4 sm:gap-4 xl:grid-cols-4">
{stats.map((stat, i) => ( {stats.map((stat, i) => (
<motion.div <motion.button
key={stat.name} key={stat.name}
type="button"
onClick={() => handleStatCardClick(stat.metricKey)}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }} transition={{ delay: i * 0.1 }}
className="crm-card min-h-[88px] rounded-xl p-3 transition-all hover:shadow-md dark:hover:bg-slate-900 sm:min-h-[104px] sm:rounded-2xl sm:p-5" className="crm-card min-h-[88px] rounded-xl p-3 text-left transition-all hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 dark:hover:bg-slate-900 sm:min-h-[104px] sm:rounded-2xl sm:p-5"
whileTap={{ scale: 0.98 }}
aria-label={`查看${stat.name}`}
> >
<div className="flex items-center gap-2.5 sm:gap-4"> <div className="flex items-center gap-2.5 sm:gap-4">
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${stat.bg} sm:h-12 sm:w-12 sm:rounded-xl`}> <div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${stat.bg} sm:h-12 sm:w-12 sm:rounded-xl`}>
@ -140,7 +161,7 @@ export default function Dashboard() {
<p className="mt-1 text-lg font-bold leading-none text-slate-900 dark:text-white sm:text-2xl">{stat.value ?? 0}</p> <p className="mt-1 text-lg font-bold leading-none text-slate-900 dark:text-white sm:text-2xl">{stat.value ?? 0}</p>
</div> </div>
</div> </div>
</motion.div> </motion.button>
))} ))}
</div> </div>

View File

@ -1,6 +1,7 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { Search, Plus, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react"; import { Search, Plus, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { useLocation } from "react-router-dom";
import { import {
createChannelExpansion, createChannelExpansion,
createSalesExpansion, createSalesExpansion,
@ -129,6 +130,7 @@ function DetailItem({
} }
export default function Expansion() { export default function Expansion() {
const location = useLocation();
const [activeTab, setActiveTab] = useState<ExpansionTab>("sales"); const [activeTab, setActiveTab] = useState<ExpansionTab>("sales");
const [selectedItem, setSelectedItem] = useState<ExpansionItem | null>(null); const [selectedItem, setSelectedItem] = useState<ExpansionItem | null>(null);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
@ -155,6 +157,13 @@ export default function Expansion() {
const [editChannelForm, setEditChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm); const [editChannelForm, setEditChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
const hasForegroundModal = createOpen || editOpen; const hasForegroundModal = createOpen || editOpen;
useEffect(() => {
const requestedTab = (location.state as { tab?: ExpansionTab } | null)?.tab;
if (requestedTab === "sales" || requestedTab === "channel") {
setActiveTab(requestedTab);
}
}, [location.state]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,4 @@
import{d2 as c,bI as o}from"./index-CYM97J2V.js";const r=new o("antFadeIn",{"0%":{opacity:0},"100%":{opacity:1}}),s=new o("antFadeOut",{"0%":{opacity:1},"100%":{opacity:0}}),p=(t,a=!1)=>{const{antCls:e}=t,n=`${e}-fade`,i=a?"&":"";return[c(n,r,s,t.motionDurationMid,a),{[`
${i}${n}-enter,
${i}${n}-appear
`]:{opacity:0,animationTimingFunction:"linear"},[`${i}${n}-leave`]:{animationTimingFunction:"linear"}}]};export{p as i};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.dictionaries-page{padding:24px;height:100%;display:flex;flex-direction:column}.dictionaries-header{margin-bottom:24px}.dictionaries-title{margin-bottom:4px!important}.dictionaries-content{flex:1;min-height:0}.full-height{height:100%}.full-height-card{height:100%;display:flex;flex-direction:column}.full-height-card .ant-card-body{flex:1;overflow:hidden;padding:0}.scroll-container{height:100%;overflow-y:auto;padding:12px}.dict-type-row{transition:all .3s}.dict-type-row:hover{background-color:#f5f5f5}.dict-type-row-selected{background-color:#e6f7ff!important;border-right:3px solid #1890ff}.dict-type-item{display:flex;justify-content:space-between;align-items:center;padding:4px 0}.dict-type-name{font-weight:600;color:#262626}.dict-type-code{font-size:12px;color:#8c8c8c}.dict-type-actions{display:flex;gap:4px;opacity:.6;transition:opacity .3s}.dict-type-row:hover .dict-type-actions{opacity:1}.tabular-nums{font-variant-numeric:tabular-nums}.flex-center{display:flex;align-items:center;justify-content:center}.h-full{height:100%}

View File

@ -0,0 +1 @@
.devices-page{padding:24px}.devices-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:24px}.devices-title{margin-bottom:4px!important}.devices-table-card{border-radius:8px}.devices-table-toolbar{margin-bottom:20px}.devices-search-input{max-width:400px}.device-icon-placeholder{width:40px;height:40px;background-color:#f0f5ff;border-radius:8px;display:flex;align-items:center;justify-content:center;color:#1890ff;font-size:20px}.device-name{font-weight:600;color:#262626}.device-code{font-size:12px;color:#8c8c8c}.device-drawer-title{display:flex;align-items:center;font-size:16px;font-weight:600}.tabular-nums{font-variant-numeric:tabular-nums}

View File

@ -0,0 +1 @@
import{j as e,T as d}from"./index-CYM97J2V.js";const{Title:i,Text:n}=d,c=({title:r,subtitle:s,extra:a,className:l=""})=>e.jsxs("div",{className:`page-header flex justify-between items-end mb-6 ${l}`,children:[e.jsxs("div",{children:[e.jsx(i,{level:4,className:"mb-1",style:{margin:0},children:r}),s&&e.jsx(n,{type:"secondary",style:{display:"block"},children:s})]}),a&&e.jsx("div",{className:"page-header-extra",children:a})]});export{c as P};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.roles-page-v2{background:transparent;display:flex;flex-direction:column}.roles-layout{flex:1;min-height:0;display:flex;padding-bottom:24px}.roles-layout__row{width:100%;margin:0!important;height:100%}.roles-layout__side,.roles-layout__detail{height:100%;display:flex;flex-direction:column}.roles-side-card,.roles-detail-card{flex:1;min-height:0;border-radius:18px!important;display:flex;flex-direction:column;box-shadow:0 4px 20px #0000000a!important;border:1px solid rgba(226,232,240,.8)!important}.roles-side-card .ant-card-body,.roles-detail-card .ant-card-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:16px!important;overflow:hidden}.role-search-panel{flex-shrink:0;display:flex;flex-direction:column;gap:12px;margin-bottom:16px}.role-search-bar{display:flex;gap:10px}.role-search-bar .ant-input-affix-wrapper{flex:1;border-radius:8px}.role-search-bar .ant-btn{border-radius:8px}.role-list-container-v3{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding-right:8px;margin-right:-8px}.role-list-container-v3::-webkit-scrollbar{width:6px}.role-list-container-v3::-webkit-scrollbar-track{background:transparent}.role-list-container-v3::-webkit-scrollbar-thumb{background:#e2e8f0;border-radius:10px}.role-list-container-v3::-webkit-scrollbar-thumb:hover{background:#cbd5e1}.role-item-card-v3{display:flex;align-items:center;gap:12px;padding:14px 16px;margin-bottom:10px;border:1px solid #f1f5f9;border-radius:12px;background:#fff;transition:all .2s ease;cursor:pointer;position:relative;overflow:hidden}.role-item-card-v3:hover{border-color:#cbd5e1;background:#f8fafc;transform:translateY(-1px);box-shadow:0 4px 12px #00000008}.role-item-card-v3.active{border-color:#bfdbfe;background:#eff6ff}.role-item-card-v3.active:before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:#3b82f6}.role-item-card-v3.active .role-name{color:#1e3a8a;font-weight:600}.role-item-card-v3.active .role-item-symbol{background:#3b82f6;color:#fff}.role-item-symbol{width:40px;height:40px;border-radius:10px;background:#f1f5f9;color:#64748b;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;transition:all .2s ease}.role-item-main{flex:1;min-width:0}.role-item-name-row{display:flex;align-items:center;gap:8px;margin-bottom:4px}.role-name{font-size:14px;font-weight:500;color:#334155;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.role-code{font-size:12px;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.role-item-actions{display:flex;gap:4px;opacity:0;transition:opacity .2s ease}.role-item-actions .ant-btn{color:#64748b}.role-item-actions .ant-btn:hover{color:#3b82f6;background:#e2e8f0}.role-item-actions .ant-btn.ant-btn-dangerous:hover{color:#ef4444;background:#fee2e2}.role-item-card-v3:hover .role-item-actions,.role-item-card-v3.active .role-item-actions{opacity:1}.role-list-pagination{flex-shrink:0;padding-top:16px;margin-top:auto;border-top:1px solid #f1f5f9;display:flex;justify-content:flex-end}.role-detail-header{display:flex;align-items:center;gap:16px}.role-detail-icon{width:48px;height:48px;border-radius:12px;background:#eff6ff;color:#3b82f6;display:flex;align-items:center;justify-content:center;font-size:24px}.role-detail-heading{display:flex;flex-direction:column;min-width:0}.role-detail-title{font-size:18px;font-weight:600;color:#1e293b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.role-detail-code{font-size:13px;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.role-detail-tabs{flex:1;display:flex;flex-direction:column;min-height:0}.role-detail-tabs .ant-tabs-nav{margin-bottom:16px!important}.role-detail-tabs .ant-tabs-nav:before{border-bottom:1px solid #f1f5f9}.role-detail-tabs .ant-tabs-content-holder{flex:1;min-height:0;overflow-y:auto}.role-detail-tabs .ant-tabs-content-holder::-webkit-scrollbar{width:6px}.role-detail-tabs .ant-tabs-content-holder::-webkit-scrollbar-track{background:transparent}.role-detail-tabs .ant-tabs-content-holder::-webkit-scrollbar-thumb{background:#e2e8f0;border-radius:10px}.role-detail-pane{padding:4px}.permission-tree-wrapper{padding:16px;background:#f8fafc;border-radius:12px;border:1px solid #e2e8f0}.role-members-toolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.role-members-toolbar h5.ant-typography{color:#334155;font-weight:600}.roles-count-badge{background:#f1f5f9!important;color:#64748b!important;border:1px solid #e2e8f0}.app-page__empty-state{height:100%;display:flex;align-items:center;justify-content:center;background:#ffffffc7;border-radius:16px;border:1px dashed rgba(148,163,184,.5);margin:0}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.sys-params-page{min-height:100%}.sys-params-header{display:flex;justify-content:space-between;align-items:flex-end}.sys-params-table-card{border-radius:8px}.sys-params-table-toolbar{display:flex;justify-content:space-between;align-items:center}.sys-params-search-input{border-radius:6px}.param-key-text{font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-weight:500;color:#1890ff}.param-type-tag{border-radius:4px}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.platform-settings-page{max-width:1280px;height:100%;min-height:0;overflow:hidden}.platform-settings-scroll{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:0 4px 32px 0}.platform-settings-form{width:100%}.platform-settings-page .app-page__content-card{overflow:visible}.platform-settings-page .ant-row{row-gap:24px}@media (max-height: 1080px){.platform-settings-scroll{padding-bottom:48px}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.permissions-page{display:flex;flex-direction:column;height:100%}.permissions-content-card{flex:1;min-height:0;display:flex;flex-direction:column;border-radius:18px!important;border:1px solid rgba(226,232,240,.8)!important;box-shadow:0 4px 20px #0000000a!important;overflow:hidden}.permissions-content-card .ant-card-body{flex:1;min-height:0;display:flex;flex-direction:column;padding:0!important;overflow:hidden}.permissions-table-full{flex:1;min-height:0;display:flex;flex-direction:column;width:100%}.permissions-table-full .ant-spin-nested-loading,.permissions-table-full .ant-spin-container,.permissions-table-full .ant-table,.permissions-table-full .ant-table-container{flex:1;min-height:0;display:flex;flex-direction:column}.permissions-table-full .ant-table-body{flex:1;min-height:0;max-height:none!important;overflow-y:auto!important}.permissions-table-full .ant-table-body::-webkit-scrollbar{width:6px;height:6px}.permissions-table-full .ant-table-body::-webkit-scrollbar-track{background:transparent}.permissions-table-full .ant-table-body::-webkit-scrollbar-thumb{background:#e2e8f0;border-radius:10px}.permissions-table-full .ant-table-body::-webkit-scrollbar-thumb:hover{background:#cbd5e1}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.list-table-container{width:100%}.list-table-container .row-selected{background-color:var(--item-hover-bg)}.list-table-container .row-selected:hover>td{background-color:var(--item-hover-bg)!important}.table-selection-info{display:inline-flex;align-items:center;gap:8px}.selection-count{color:var(--text-color-secondary);font-size:14px}.count-highlight{color:var(--link-color);font-weight:600}.selection-action{color:var(--link-color);font-size:14px;cursor:pointer;text-decoration:none;transition:color .3s;margin-left:4px}.selection-action:hover{color:var(--link-color);opacity:.8}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{x as F,r as s,H as $,ay as w,b$ as q,c0 as D,d7 as E,B as J,cQ as M,d8 as Q,C as S,A as U,av as Y,d9 as G,aJ as K}from"./index-CYM97J2V.js";import{A as Z}from"./useDict-BjF_A4fe.js";const ee=e=>{const{componentCls:n,iconCls:l,antCls:t,zIndexPopup:o,colorText:f,colorWarning:u,marginXXS:c,marginXS:i,fontSize:g,fontWeightStrong:v,colorTextHeading:y}=e;return{[n]:{zIndex:o,[`&${t}-popover`]:{fontSize:g},[`${n}-message`]:{marginBottom:i,display:"flex",flexWrap:"nowrap",alignItems:"start",[`> ${n}-message-icon ${l}`]:{color:u,fontSize:g,lineHeight:1,marginInlineEnd:i},[`${n}-title`]:{fontWeight:v,color:y,"&:only-child":{fontWeight:"normal"}},[`${n}-description`]:{marginTop:c,color:f}},[`${n}-buttons`]:{textAlign:"end",whiteSpace:"nowrap",button:{marginInlineStart:i}}}}},te=e=>{const{zIndexPopupBase:n}=e;return{zIndexPopup:n+60}},I=F("Popconfirm",e=>ee(e),te,{resetStyle:!1});var ne=function(e,n){var l={};for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&n.indexOf(t)<0&&(l[t]=e[t]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var o=0,t=Object.getOwnPropertySymbols(e);o<t.length;o++)n.indexOf(t[o])<0&&Object.prototype.propertyIsEnumerable.call(e,t[o])&&(l[t[o]]=e[t[o]]);return l};const k=e=>{const{prefixCls:n,okButtonProps:l,cancelButtonProps:t,title:o,description:f,cancelText:u,okText:c,okType:i="primary",icon:g=s.createElement($,null),showCancel:v=!0,close:y,onConfirm:C,onCancel:O,onPopupClick:m}=e,{getPrefixCls:p}=s.useContext(w),[d]=q("Popconfirm",D.Popconfirm),b=E(o),x=E(f);return s.createElement("div",{className:`${n}-inner-content`,onClick:m},s.createElement("div",{className:`${n}-message`},g&&s.createElement("span",{className:`${n}-message-icon`},g),s.createElement("div",{className:`${n}-message-text`},b&&s.createElement("div",{className:`${n}-title`},b),x&&s.createElement("div",{className:`${n}-description`},x))),s.createElement("div",{className:`${n}-buttons`},v&&s.createElement(J,Object.assign({onClick:O,size:"small"},t),u||(d==null?void 0:d.cancelText)),s.createElement(Z,{buttonProps:Object.assign(Object.assign({size:"small"},M(i)),l),actionFn:C,close:y,prefixCls:p("btn"),quitOnNullishReturnValue:!0,emitEvent:!0},c||(d==null?void 0:d.okText))))},oe=e=>{const{prefixCls:n,placement:l,className:t,style:o}=e,f=ne(e,["prefixCls","placement","className","style"]),{getPrefixCls:u}=s.useContext(w),c=u("popconfirm",n),[i]=I(c);return i(s.createElement(Q,{placement:l,className:S(c,t),style:o,content:s.createElement(k,Object.assign({prefixCls:c},f))}))};var se=function(e,n){var l={};for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&n.indexOf(t)<0&&(l[t]=e[t]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var o=0,t=Object.getOwnPropertySymbols(e);o<t.length;o++)n.indexOf(t[o])<0&&Object.prototype.propertyIsEnumerable.call(e,t[o])&&(l[t[o]]=e[t[o]]);return l};const le=s.forwardRef((e,n)=>{var l,t;const{prefixCls:o,placement:f="top",trigger:u="click",okType:c="primary",icon:i=s.createElement($,null),children:g,overlayClassName:v,onOpenChange:y,onVisibleChange:C,overlayStyle:O,styles:m,classNames:p}=e,d=se(e,["prefixCls","placement","trigger","okType","icon","children","overlayClassName","onOpenChange","onVisibleChange","overlayStyle","styles","classNames"]),{getPrefixCls:b,className:x,style:T,classNames:j,styles:h}=U("popconfirm"),[_,z]=Y(!1,{value:(l=e.open)!==null&&l!==void 0?l:e.visible,defaultValue:(t=e.defaultOpen)!==null&&t!==void 0?t:e.defaultVisible}),P=(a,r)=>{z(a,!0),C==null||C(a),y==null||y(a,r)},B=a=>{P(!1,a)},V=a=>{var r;return(r=e.onConfirm)===null||r===void 0?void 0:r.call(void 0,a)},W=a=>{var r;P(!1,a),(r=e.onCancel)===null||r===void 0||r.call(void 0,a)},A=(a,r)=>{const{disabled:X=!1}=e;X||P(a,r)},N=b("popconfirm",o),H=S(N,x,v,j.root,p==null?void 0:p.root),R=S(j.body,p==null?void 0:p.body),[L]=I(N);return L(s.createElement(G,Object.assign({},K(d,["title"]),{trigger:u,placement:f,onOpenChange:A,open:_,ref:n,classNames:{root:H,body:R},styles:{root:Object.assign(Object.assign(Object.assign(Object.assign({},h.root),T),O),m==null?void 0:m.root),body:Object.assign(Object.assign({},h.body),m==null?void 0:m.body)},content:s.createElement(k,Object.assign({okType:c,icon:i},e,{prefixCls:N,close:B,onConfirm:V,onCancel:W})),"data-popover-inject":!0}),g))}),ae=le;ae._InternalPanelDoNotUseOrYouWillBeFired=oe;export{ae as P};

View File

@ -0,0 +1 @@
.users-page{padding:24px}.users-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:24px}.users-title{margin-bottom:4px!important}.users-table-card{border-radius:8px;box-shadow:0 2px 8px #0000000d}.users-table-toolbar{margin-bottom:20px}.users-search-input{max-width:400px}.user-avatar-placeholder{width:40px;height:40px;background-color:#f0f2f5;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#1890ff;font-size:20px}.user-display-name{font-weight:600;color:#262626}.user-username{font-size:12px;color:#8c8c8c}.user-phone{color:#8c8c8c;font-size:13px}.user-drawer-title{display:flex;align-items:center;font-size:16px;font-weight:600}.user-drawer-footer{display:flex;justify-content:flex-end;gap:12px;padding:10px 0}.user-form .ant-form-item{margin-bottom:20px}.user-form .ant-row{margin-left:-8px!important;margin-right:-8px!important}.user-form .ant-col{padding-left:8px!important;padding-right:8px!important}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.stat-card{padding:16px;background:#fff;border-radius:8px;border:1px solid #f0f0f0;transition:all .3s ease}.stat-card:hover{border-color:#d9d9d9;box-shadow:0 2px 8px #00000014}.stat-card.stat-card-row{display:flex;align-items:center;gap:16px}.stat-card.stat-card-row .stat-card-header{flex:1;margin-bottom:0;display:flex;flex-direction:column;align-items:flex-start;gap:8px}.stat-card.stat-card-row .stat-card-body{flex-shrink:0;display:flex;flex-direction:column;align-items:flex-end;gap:4px}.stat-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.stat-card-title{font-size:13px;color:#000000a6;font-weight:500}.stat-card-icon{font-size:18px;display:flex;align-items:center}.stat-card-body{display:flex;align-items:flex-end;justify-content:space-between;gap:8px}.stat-card-value{font-size:24px;font-weight:600;line-height:1}.stat-card-suffix{font-size:14px;font-weight:400;margin-left:4px;color:#00000073}.stat-card-trend{display:flex;align-items:center;gap:4px;font-size:12px;font-weight:500;padding:2px 6px;border-radius:4px}.stat-card-trend.trend-up{color:#52c41a;background:#f6ffed}.stat-card-trend.trend-down{color:#ff4d4f;background:#fff1f0}.stat-card-trend svg{font-size:10px}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{j as e,m as N,n as I,u as R,T as $,o as c,p as k,B as l,q as w,t as T,v as C,w as M,R as S,S as _}from"./index-CYM97J2V.js";import{P as D}from"./index-B7x062Ky.js";import{T as o}from"./index-DqftVe8j.js";import{R as h,C as r}from"./row-DNGQ3hV2.js";import{C as p}from"./index-Bcf8yIZZ.js";import{F as E}from"./Table-CAs2vM8c.js";import"./useForm-B5uILH8t.js";import"./index-M0Ae7f04.js";import"./Pagination-By0LqcoC.js";function d({title:a,value:n,icon:i,color:s="blue",trend:t,suffix:u="",layout:f="column",gridColumn:m,className:b="",onClick:g,style:y={}}){const x={blue:"#1677ff",green:"#52c41a",orange:"#faad14",red:"#ff4d4f",purple:"#722ed1",gray:"#8c8c8c"}[s]||s,v={...m?{gridColumn:m}:{},...y};return e.jsxs("div",{className:`stat-card stat-card-${f} ${b}`,style:v,onClick:g,children:[e.jsxs("div",{className:"stat-card-header",children:[e.jsx("span",{className:"stat-card-title",children:a}),i&&e.jsx("span",{className:"stat-card-icon",style:{color:x},"aria-hidden":"true",children:i})]}),e.jsxs("div",{className:"stat-card-body",children:[e.jsxs("div",{className:"stat-card-value tabular-nums",style:{color:x},children:[n,u&&e.jsx("span",{className:"stat-card-suffix",children:u})]}),t&&e.jsxs("div",{className:`stat-card-trend ${t.direction==="up"?"trend-up":"trend-down"} tabular-nums`,"aria-label":`${t.direction==="up"?"Increase":"Decrease"} of ${t.value}%`,children:[t.direction==="up"?e.jsx(N,{"aria-hidden":"true"}):e.jsx(I,{"aria-hidden":"true"}),e.jsxs("span",{children:[Math.abs(t.value),"%"]})]})]})]})}const{Text:j}=$;function J(){const{t:a}=R(),n=[{key:"1",name:"Product Sync",time:"2024-02-10 14:00",duration:"45min",status:"processing"},{key:"2",name:"Tech Review",time:"2024-02-10 10:00",duration:"60min",status:"success"},{key:"3",name:"Daily Standup",time:"2024-02-10 09:00",duration:"15min",status:"success"},{key:"4",name:"Client Call",time:"2024-02-10 16:30",duration:"30min",status:"default"}],i=[{title:a("dashboard.meetingName"),dataIndex:"name",key:"name",render:s=>e.jsx(j,{strong:!0,children:s})},{title:a("dashboard.startTime"),dataIndex:"time",key:"time",className:"tabular-nums",render:s=>e.jsx(j,{type:"secondary",children:s})},{title:a("dashboard.duration"),dataIndex:"duration",key:"duration",width:100,className:"tabular-nums"},{title:a("common.status"),dataIndex:"status",key:"status",width:120,render:s=>s==="processing"?e.jsx(o,{icon:e.jsx(c,{spin:!0,"aria-hidden":"true"}),color:"processing",children:a("dashboardExt.processing")}):s==="success"?e.jsx(o,{icon:e.jsx(k,{"aria-hidden":"true"}),color:"success",children:a("dashboardExt.completed")}):e.jsx(o,{color:"default",children:a("dashboardExt.pending")})},{title:a("common.action"),key:"action",width:80,render:()=>e.jsx(l,{type:"link",size:"small",icon:e.jsx(w,{"aria-hidden":"true"}),"aria-label":a("dashboard.viewAll")})}];return e.jsxs("div",{className:"app-page dashboard-page",children:[e.jsx(D,{title:a("dashboard.title"),subtitle:a("dashboard.subtitle")}),e.jsx("div",{className:"app-page__page-actions",children:e.jsx(l,{icon:e.jsx(c,{"aria-hidden":"true"}),size:"small",children:a("common.refresh")})}),e.jsxs(h,{gutter:[24,24],children:[e.jsx(r,{xs:24,sm:12,lg:6,children:e.jsx(d,{title:a("dashboard.todayMeetings"),value:12,icon:e.jsx(T,{"aria-hidden":"true"}),color:"blue",trend:{value:8,direction:"up"}})}),e.jsx(r,{xs:24,sm:12,lg:6,children:e.jsx(d,{title:a("dashboard.activeDevices"),value:45,icon:e.jsx(C,{"aria-hidden":"true"}),color:"green",trend:{value:2,direction:"up"}})}),e.jsx(r,{xs:24,sm:12,lg:6,children:e.jsx(d,{title:a("dashboard.transcriptionDuration"),value:1280,suffix:"min",icon:e.jsx(M,{"aria-hidden":"true"}),color:"orange",trend:{value:5,direction:"down"}})}),e.jsx(r,{xs:24,sm:12,lg:6,children:e.jsx(d,{title:a("dashboard.totalUsers"),value:320,icon:e.jsx(S,{"aria-hidden":"true"}),color:"purple",trend:{value:12,direction:"up"}})})]}),e.jsxs(h,{gutter:[24,24],className:"mt-6",children:[e.jsx(r,{xs:24,xl:16,children:e.jsx(p,{title:a("dashboard.recentMeetings"),bordered:!1,className:"app-page__content-card",extra:e.jsx(l,{type:"link",size:"small",children:a("dashboard.viewAll")}),styles:{body:{padding:0}},children:e.jsx(E,{dataSource:n,columns:i,pagination:!1,size:"middle",className:"roles-table"})})}),e.jsx(r,{xs:24,xl:8,children:e.jsx(p,{title:a("dashboard.deviceLoad"),bordered:!1,className:"app-page__content-card",children:e.jsxs("div",{className:"flex flex-col items-center justify-center py-12",children:[e.jsx(_,{active:!0,paragraph:{rows:4}}),e.jsxs("div",{className:"mt-4 text-gray-400 flex items-center gap-2",children:[e.jsx(c,{spin:!0,"aria-hidden":"true"}),e.jsx("span",{children:a("dashboardExt.chartLoading")})]})]})})})]})]})}export{J as default};

View File

@ -0,0 +1 @@
import{r as u,h as p,j as e,L as f,a as o,T as j,B as l,i as w,k as y,s as g}from"./index-CYM97J2V.js";import{F as s}from"./index-CacOxAQN.js";import{C as h}from"./index-Bcf8yIZZ.js";import{I as a}from"./index-DV4zjsPW.js";import"./useForm-B5uILH8t.js";import"./row-DNGQ3hV2.js";const{Title:P,Text:b}=j;function F(){const[d,t]=u.useState(!1),c=p(),[m]=s.useForm(),n=()=>{localStorage.clear(),sessionStorage.clear(),c("/login")},x=async r=>{t(!0);try{await y({oldPassword:r.oldPassword,newPassword:r.newPassword}),g.success("密码已更新,请重新登录"),n()}finally{t(!1)}};return e.jsx(f,{style:{minHeight:"100vh",background:"#f0f2f5",display:"flex",alignItems:"center",justifyContent:"center"},children:e.jsxs(h,{style:{width:420,borderRadius:8,boxShadow:"0 4px 12px rgba(0,0,0,0.1)"},children:[e.jsxs("div",{className:"text-center mb-6",children:[e.jsx(o,{style:{fontSize:40,color:"#1890ff"}}),e.jsx(P,{level:3,style:{marginTop:16},children:"首次登录请修改密码"}),e.jsx(b,{type:"secondary",children:"当前账号被要求更新初始密码,提交成功后会跳转到登录页。"})]}),e.jsxs(s,{form:m,layout:"vertical",onFinish:x,children:[e.jsx(s.Item,{label:"当前密码",name:"oldPassword",rules:[{required:!0,message:"请输入当前密码"}],children:e.jsx(a.Password,{prefix:e.jsx(o,{})})}),e.jsx(s.Item,{label:"新密码",name:"newPassword",rules:[{required:!0,min:6,message:"新密码至少 6 位"}],children:e.jsx(a.Password,{prefix:e.jsx(o,{})})}),e.jsx(s.Item,{label:"确认新密码",name:"confirmPassword",dependencies:["newPassword"],rules:[{required:!0,message:"请再次输入新密码"},({getFieldValue:r})=>({validator(T,i){return!i||r("newPassword")===i?Promise.resolve():Promise.reject(new Error("两次输入的新密码不一致"))}})],children:e.jsx(a.Password,{prefix:e.jsx(o,{})})}),e.jsx(l,{type:"primary",htmlType:"submit",block:!0,size:"large",loading:d,style:{marginTop:8},children:"提交并重新登录"}),e.jsx(l,{type:"link",block:!0,icon:e.jsx(w,{}),onClick:n,style:{marginTop:8},children:"退出登录"})]})]})})}export{F as default};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.login-page{min-height:100vh;display:flex;background:#fff}.login-left{flex:1.1;background:linear-gradient(140deg,#f0f5ff,#eef5ff 40%,#f7fbff);padding:56px 64px;display:flex;flex-direction:column;justify-content:space-between;position:relative;overflow:hidden}.login-brand{display:flex;align-items:center;gap:10px;z-index:1}.brand-logo-img{width:34px;height:34px;filter:drop-shadow(0 8px 16px rgba(45,107,255,.24))}.brand-name{font-size:20px;font-weight:600;color:#2f3a4f;letter-spacing:-.2px}.login-hero{margin-top:-40px;z-index:1}.hero-title{font-size:42px;font-weight:700;line-height:1.2;color:#1d2b3a;margin-bottom:24px;letter-spacing:-.5px;text-wrap:balance}.hero-accent{color:#1677ff;position:relative}.hero-desc{font-size:16px;line-height:1.8;color:#687489;max-width:440px}.login-left-footer{display:flex;align-items:center;gap:12px;color:#8c97a8;font-size:13px;z-index:1}.footer-divider{width:4px;height:4px;background:#c4ccd7;border-radius:50%}.login-right{flex:1;display:flex;align-items:center;justify-content:center;padding:40px;background:#fff}.login-container{width:100%;max-width:400px}.login-header{margin-bottom:40px}.login-header h2{font-size:28px!important;font-weight:700!important;color:#1f2a37!important;margin-bottom:8px!important;letter-spacing:-.5px}.login-header span{font-size:15px;color:#6b7280}.login-form .ant-form-item{margin-bottom:20px}.login-form .ant-input-affix-wrapper-lg{padding:10px 16px;border-radius:8px}.captcha-wrapper{display:flex;gap:12px}.captcha-image-btn{padding:0;width:120px;height:46px;flex-shrink:0;border-radius:8px;overflow:hidden;display:flex;align-items:center;justify-content:center;background:#f9fafb}.captcha-image-btn img{width:100%;height:100%;object-fit:cover;transition:opacity .2s}.captcha-image-btn:hover img{opacity:.8}.login-extra{display:flex;justify-content:space-between;align-items:center;margin-top:-4px;margin-bottom:24px}.forgot-password{font-size:14px;color:#1677ff}.login-submit-btn{height:48px;font-size:16px;font-weight:600;border-radius:8px}.login-footer{margin-top:32px;text-align:center;padding:16px;background:#f9fafb;border-radius:12px}.tabular-nums{font-variant-numeric:tabular-nums}@media (max-width: 1024px){.login-left{padding:48px}.hero-title{font-size:36px}}@media (max-width: 900px){.login-left{display:none}.login-right{background:#f3f4f6}.login-container{background:#fff;padding:48px 32px;border-radius:20px;box-shadow:0 20px 25px -5px #0000001a,0 10px 10px -5px #0000000a}}@media (max-width: 480px){.login-right{padding:20px}.login-container{padding:32px 20px}}

View File

@ -0,0 +1 @@
import{u as W,r as t,j as e,B as M,W as Y,a3 as $,Y as c,R as v,T as F,a5 as K,b3 as P,aU as D,aT as G,aW as H,s as w,a_ as q}from"./index-CYM97J2V.js";import{P as A}from"./index-B7x062Ky.js";import{R as b,C as d}from"./row-DNGQ3hV2.js";import{C as N}from"./index-Bcf8yIZZ.js";import{I as J}from"./index-DV4zjsPW.js";import{F as O}from"./Table-CAs2vM8c.js";import{T as u}from"./index-DqftVe8j.js";import{C as S}from"./index-M0Ae7f04.js";import"./useForm-B5uILH8t.js";import"./Pagination-By0LqcoC.js";const{Text:U}=F;function oe(){const{t:a}=W(),[o,C]=t.useState([]),[h,I]=t.useState([]),[T,m]=t.useState(!1),[p,x]=t.useState(!1),[f,g]=t.useState(!1),[l,y]=t.useState(null),[j,i]=t.useState([]),[n,_]=t.useState(""),R=t.useMemo(()=>o.find(s=>s.userId===l)||null,[o,l]),k=async()=>{m(!0);try{const s=await D();C(s||[])}finally{m(!1)}},z=async()=>{x(!0);try{const s=await G();I(s||[])}finally{x(!1)}},E=async s=>{try{const r=await H(s);i(r||[])}catch{i([])}};t.useEffect(()=>{k(),z()},[]),t.useEffect(()=>{l?E(l):i([])},[l]);const L=t.useMemo(()=>{if(!n)return o;const s=n.toLowerCase();return o.filter(r=>r.username.toLowerCase().includes(s)||r.displayName.toLowerCase().includes(s))},[o,n]),B=async()=>{if(!l){w.warning(a("userRole.selectUser"));return}g(!0);try{await q(l,j),w.success(a("common.success"))}finally{g(!1)}};return e.jsxs("div",{className:"app-page",children:[e.jsx(A,{title:a("userRole.title"),subtitle:a("userRole.subtitle")}),e.jsx("div",{className:"app-page__page-actions",children:e.jsx(M,{type:"primary",icon:e.jsx(Y,{"aria-hidden":"true"}),onClick:B,loading:f,disabled:!l,children:a(f?"common.loading":"common.save")})}),e.jsxs(b,{gutter:24,className:"app-page__split",style:{height:"calc(100vh - 180px)"},children:[e.jsx(d,{xs:24,lg:12,style:{height:"100%"},children:e.jsxs(N,{title:e.jsxs(c,{children:[e.jsx(v,{"aria-hidden":"true"}),e.jsx("span",{children:a("userRole.userList")})]}),className:"app-page__panel-card full-height-card",children:[e.jsx("div",{className:"mb-4",children:e.jsx(J,{placeholder:a("userRole.searchUser"),prefix:e.jsx($,{"aria-hidden":"true",className:"text-gray-400"}),value:n,onChange:s=>_(s.target.value),allowClear:!0,"aria-label":a("userRole.searchUser")})}),e.jsx("div",{style:{height:"calc(100% - 60px)",overflowY:"auto"},children:e.jsx(O,{rowKey:"userId",size:"middle",loading:T,dataSource:L,rowSelection:{type:"radio",selectedRowKeys:l?[l]:[],onChange:s=>y(s[0])},onRow:s=>({onClick:()=>y(s.userId),className:"cursor-pointer"}),pagination:{pageSize:10,showTotal:s=>a("common.total",{total:s})},columns:[{title:a("users.userInfo"),key:"user",render:(s,r)=>e.jsxs("div",{className:"min-w-0",children:[e.jsx("div",{style:{fontWeight:500},className:"truncate",children:r.displayName}),e.jsxs("div",{style:{fontSize:12,color:"#8c8c8c"},className:"truncate",children:["@",r.username]})]})},{title:a("common.status"),dataIndex:"status",width:80,render:s=>s===1?e.jsx(u,{color:"green",className:"m-0",children:"Enabled"}):e.jsx(u,{className:"m-0",children:"Disabled"})}]})})]})}),e.jsx(d,{xs:24,lg:12,style:{height:"100%"},children:e.jsx(N,{title:e.jsxs(c,{children:[e.jsx(P,{"aria-hidden":"true"}),e.jsx("span",{children:a("userRole.grantRoles")})]}),className:"app-page__panel-card full-height-card",extra:R?e.jsxs(u,{color:"blue",children:[a("userRole.editing"),": ",R.displayName]}):null,children:l?e.jsxs("div",{style:{padding:"8px 0",height:"100%",overflowY:"auto"},children:[e.jsx(S.Group,{style:{width:"100%"},value:j,onChange:s=>i(s),disabled:p,children:e.jsx(b,{gutter:[16,16],children:h.map(s=>e.jsx(d,{span:12,children:e.jsx(S,{value:s.roleId,className:"w-full",children:e.jsxs(c,{direction:"vertical",size:0,children:[e.jsx("span",{style:{fontWeight:500},children:s.roleName}),e.jsx(U,{type:"secondary",style:{fontSize:12},className:"tabular-nums",children:s.roleCode})]})})},s.roleId))})}),!h.length&&!p&&e.jsx(K,{description:"No roles available"})]}):e.jsxs("div",{className:"flex flex-col items-center justify-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200",children:[e.jsx(v,{style:{fontSize:40,color:"#bfbfbf",marginBottom:16},"aria-hidden":"true"}),e.jsx(U,{type:"secondary",children:a("userRole.selectUser")})]})})})]})]})}export{oe as default};

View File

@ -0,0 +1 @@
import{bm as t}from"./index-CYM97J2V.js";async function n(a){return(await t.get("/sys/api/orgs",{params:{tenantId:a}})).data.data}async function p(a){return(await t.post("/sys/api/orgs",a)).data.data}async function o(a,s){return(await t.put(`/sys/api/orgs/${a}`,s)).data.data}async function c(a){return(await t.delete(`/sys/api/orgs/${a}`)).data.data}export{p as c,c as d,n as l,o as u};

View File

@ -0,0 +1 @@
import{cH as r}from"./index-CYM97J2V.js";const s=(t,o,a,e)=>({total:t,current:o,pageSize:a,onChange:e,showSizeChanger:!0,showQuickJumper:!0,showTotal:n=>r.t("common.total",{total:n}),pageSizeOptions:["10","20","50","100"]});export{s as g};

View File

@ -0,0 +1 @@
import{r as f,ay as k,C as A,bk as S,bj as I}from"./index-CYM97J2V.js";import{c as _,d as J}from"./useForm-B5uILH8t.js";const G=f.createContext({});var M=function(e,l){var n={};for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&l.indexOf(t)<0&&(n[t]=e[t]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var r=0,t=Object.getOwnPropertySymbols(e);r<t.length;r++)l.indexOf(t[r])<0&&Object.prototype.propertyIsEnumerable.call(e,t[r])&&(n[t[r]]=e[t[r]]);return n};function R(e){return e==="auto"?"1 1 auto":typeof e=="number"?`${e} ${e} auto`:/^\d+(\.\d+)?(px|em|rem|%)$/.test(e)?`0 0 ${e}`:e}const z=["xs","sm","md","lg","xl","xxl"],H=f.forwardRef((e,l)=>{const{getPrefixCls:n,direction:t}=f.useContext(k),{gutter:r,wrap:c}=f.useContext(G),{prefixCls:p,span:i,order:g,offset:m,push:h,pull:O,className:E,children:b,flex:x,style:C}=e,d=M(e,["prefixCls","span","order","offset","push","pull","className","children","flex","style"]),o=n("col",p),[N,P,y]=_(o),j={};let $={};z.forEach(a=>{let s={};const v=e[a];typeof v=="number"?s.span=v:typeof v=="object"&&(s=v||{}),delete d[a],$=Object.assign(Object.assign({},$),{[`${o}-${a}-${s.span}`]:s.span!==void 0,[`${o}-${a}-order-${s.order}`]:s.order||s.order===0,[`${o}-${a}-offset-${s.offset}`]:s.offset||s.offset===0,[`${o}-${a}-push-${s.push}`]:s.push||s.push===0,[`${o}-${a}-pull-${s.pull}`]:s.pull||s.pull===0,[`${o}-rtl`]:t==="rtl"}),s.flex&&($[`${o}-${a}-flex`]=!0,j[`--${o}-${a}-flex`]=R(s.flex))});const w=A(o,{[`${o}-${i}`]:i!==void 0,[`${o}-order-${g}`]:g,[`${o}-offset-${m}`]:m,[`${o}-push-${h}`]:h,[`${o}-pull-${O}`]:O},E,$,P,y),u={};if(r!=null&&r[0]){const a=typeof r[0]=="number"?`${r[0]/2}px`:`calc(${r[0]} / 2)`;u.paddingLeft=a,u.paddingRight=a}return x&&(u.flex=R(x),c===!1&&!u.minWidth&&(u.minWidth=0)),N(f.createElement("div",Object.assign({},d,{style:Object.assign(Object.assign(Object.assign({},u),C),j),className:w,ref:l}),b))});function B(e,l){const n=[void 0,void 0],t=Array.isArray(e)?e:[e,void 0],r=l||{xs:!0,sm:!0,md:!0,lg:!0,xl:!0,xxl:!0};return t.forEach((c,p)=>{if(typeof c=="object"&&c!==null)for(let i=0;i<S.length;i++){const g=S[i];if(r[g]&&c[g]!==void 0){n[p]=c[g];break}}else n[p]=c}),n}var L=function(e,l){var n={};for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&l.indexOf(t)<0&&(n[t]=e[t]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var r=0,t=Object.getOwnPropertySymbols(e);r<t.length;r++)l.indexOf(t[r])<0&&Object.prototype.propertyIsEnumerable.call(e,t[r])&&(n[t[r]]=e[t[r]]);return n};function V(e,l){const[n,t]=f.useState(typeof e=="string"?e:""),r=()=>{if(typeof e=="string"&&t(e),typeof e=="object")for(let c=0;c<S.length;c++){const p=S[c];if(!l||!l[p])continue;const i=e[p];if(i!==void 0){t(i);return}}};return f.useEffect(()=>{r()},[JSON.stringify(e),l]),n}const q=f.forwardRef((e,l)=>{const{prefixCls:n,justify:t,align:r,className:c,style:p,children:i,gutter:g=0,wrap:m}=e,h=L(e,["prefixCls","justify","align","className","style","children","gutter","wrap"]),{getPrefixCls:O,direction:E}=f.useContext(k),b=I(!0,null),x=V(r,b),C=V(t,b),d=O("row",n),[o,N,P]=J(d),y=B(g,b),j=A(d,{[`${d}-no-wrap`]:m===!1,[`${d}-${C}`]:C,[`${d}-${x}`]:x,[`${d}-rtl`]:E==="rtl"},c,N,P),$={};if(y!=null&&y[0]){const s=typeof y[0]=="number"?`${y[0]/-2}px`:`calc(${y[0]} / -2)`;$.marginLeft=s,$.marginRight=s}const[w,u]=y;$.rowGap=u;const a=f.useMemo(()=>({gutter:[w,u],wrap:m}),[w,u,m]);return o(f.createElement(G.Provider,{value:a},f.createElement("div",Object.assign({},h,{className:j,style:Object.assign(Object.assign({},$),p),ref:l}),i)))});export{H as C,q as R};

View File

@ -0,0 +1 @@
import{bm as n}from"./index-CYM97J2V.js";async function r(t){return(await n.get("/sys/api/tenants",{params:t})).data.data}async function p(t){return(await n.post("/sys/api/tenants",t)).data.data}async function c(t,a){return(await n.put(`/sys/api/tenants/${t}`,a)).data.data}async function i(t){return(await n.delete(`/sys/api/tenants/${t}`)).data.data}export{p as c,i as d,r as l,c as u};

View File

@ -0,0 +1 @@
import{r as i,ds as S,B as $,cQ as E,bm as n}from"./index-CYM97J2V.js";const g=t=>typeof(t==null?void 0:t.then)=="function",x=t=>{const{type:e,children:c,prefixCls:y,buttonProps:d,close:r,autoFocus:m,emitEvent:o,isSilent:v,quitOnNullishReturnValue:D,actionFn:l}=t,u=i.useRef(!1),w=i.useRef(null),[I,h]=S(!1),p=(...a)=>{r==null||r.apply(void 0,a)};i.useEffect(()=>{let a=null;return m&&(a=setTimeout(()=>{var s;(s=w.current)===null||s===void 0||s.focus({preventScroll:!0})})),()=>{a&&clearTimeout(a)}},[m]);const T=a=>{g(a)&&(h(!0),a.then((...s)=>{h(!1,!0),p.apply(void 0,s),u.current=!1},s=>{if(h(!1,!0),u.current=!1,!(v!=null&&v()))return Promise.reject(s)}))},b=a=>{if(u.current)return;if(u.current=!0,!l){p();return}let s;if(o){if(s=l(a),D&&!g(s)){u.current=!1,p(a);return}}else if(l.length)s=l(r),u.current=!1;else if(s=l(),!g(s)){p();return}T(s)};return i.createElement($,Object.assign({},E(e),{onClick:b,loading:I,prefixCls:y},d,{ref:w}),c)};async function B(t){return(await n.get("/sys/api/dict-types",{params:t})).data.data}async function O(t){return(await n.post("/sys/api/dict-types",t)).data.data}async function P(t,e){return(await n.put(`/sys/api/dict-types/${t}`,e)).data.data}async function F(t){return(await n.delete(`/sys/api/dict-types/${t}`)).data.data}async function L(t){return(await n.get("/sys/api/dict-items",{params:{typeCode:t}})).data.data}async function j(t){return(await n.post("/sys/api/dict-items",t)).data.data}async function A(t,e){return(await n.put(`/sys/api/dict-items/${t}`,e)).data.data}async function q(t){return(await n.delete(`/sys/api/dict-items/${t}`)).data.data}async function R(t){return(await n.get(`/sys/api/dict-items/type/${t}`)).data.data}const f={};function M(t){const[e,c]=i.useState(f[t]||[]),[y,d]=i.useState(!f[t]);return i.useEffect(()=>{if(f[t]){c(f[t]),d(!1);return}let r=!0;return(async()=>{try{const o=await R(t);r&&(f[t]=o,c(o))}catch(o){console.error(`Failed to fetch dictionary ${t}:`,o)}finally{r&&d(!1)}})(),()=>{r=!1}},[t]),{items:e,loading:y}}export{x as A,L as a,P as b,O as c,F as d,q as e,B as f,A as g,j as h,M as u};

File diff suppressed because one or more lines are too long

14
frontend1/dist/index.html vendored 100644
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UnisBase - 智能会议系统</title>
<script type="module" crossorigin src="/assets/index-CYM97J2V.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

14
frontend1/dist/logo.svg vendored 100644
View File

@ -0,0 +1,14 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="10" fill="#2D6BFF"/>
<g transform="translate(16 15.6) rotate(35)" stroke="white" fill="none" stroke-width="1.8">
<rect x="-5.2" y="-10.2" width="10.4" height="10.4" rx="5.2"/>
<path d="M-2.2 0.2 H2.2 L1.1 10.4 H-1.1 Z"/>
</g>
<path
d="M12.5 22.6 C14.2 21.6 16 21.6 17.8 22.6 S21.4 23.8 23.2 22.6"
stroke="white"
stroke-width="1.8"
stroke-linecap="round"
fill="none"
/>
</svg>

After

Width:  |  Height:  |  Size: 543 B

View File

@ -1,4 +1,4 @@
import axios from "axios"; import axios from "axios";
import { message } from "antd"; import { message } from "antd";
const http = axios.create({ const http = axios.create({
@ -6,7 +6,104 @@ const http = axios.create({
timeout: 15000 timeout: 15000
}); });
http.interceptors.request.use((config) => { const refreshClient = axios.create({
baseURL: "/",
timeout: 15000
});
const AUTH_WHITELIST = ["/sys/auth/login", "/sys/auth/refresh", "/sys/auth/captcha", "/sys/auth/device-code"];
const REFRESH_AHEAD_MS = 60 * 1000;
let refreshPromise: Promise<string | null> | null = null;
function getTokenPayload(token: string): Record<string, any> | null {
try {
const payload = token.split(".")[1];
if (!payload) {
return null;
}
const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
return JSON.parse(decodeURIComponent(escape(window.atob(normalized))));
} catch {
return null;
}
}
function getTokenExpireAt(token: string): number | null {
const payload = getTokenPayload(token);
if (!payload || typeof payload.exp !== "number") {
return null;
}
return payload.exp * 1000;
}
function isTokenExpiringSoon(token: string): boolean {
const expireAt = getTokenExpireAt(token);
if (!expireAt) {
return false;
}
return expireAt - Date.now() <= REFRESH_AHEAD_MS;
}
function clearAuthStorage() {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
sessionStorage.removeItem("userProfile");
}
function persistTokens(data: { accessToken: string; refreshToken: string }) {
localStorage.setItem("accessToken", data.accessToken);
localStorage.setItem("refreshToken", data.refreshToken);
const payload = getTokenPayload(data.accessToken);
if (payload && payload.tenantId !== undefined && payload.tenantId !== null) {
localStorage.setItem("activeTenantId", String(payload.tenantId));
}
}
function isAuthWhitelistRequest(url?: string) {
return AUTH_WHITELIST.some((path) => (url || "").includes(path));
}
async function refreshAccessToken(): Promise<string | null> {
if (refreshPromise) {
return refreshPromise;
}
const refreshToken = localStorage.getItem("refreshToken");
if (!refreshToken) {
return null;
}
refreshPromise = refreshClient
.post("/sys/auth/refresh", { refreshToken })
.then((resp) => {
const body = resp.data;
if (!body || body.code !== "0" || !body.data?.accessToken || !body.data?.refreshToken) {
throw new Error(body?.msg || "刷新登录态失败");
}
persistTokens(body.data);
return body.data.accessToken as string;
})
.catch(() => {
clearAuthStorage();
return null;
})
.finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
http.interceptors.request.use(async (config) => {
if (!isAuthWhitelistRequest(config.url)) {
const currentToken = localStorage.getItem("accessToken");
if (currentToken && isTokenExpiringSoon(currentToken)) {
await refreshAccessToken();
}
}
const token = localStorage.getItem("accessToken"); const token = localStorage.getItem("accessToken");
if (token) { if (token) {
config.headers = config.headers || {}; config.headers = config.headers || {};
@ -18,10 +115,9 @@ http.interceptors.request.use((config) => {
http.interceptors.response.use( http.interceptors.response.use(
(resp) => { (resp) => {
const body = resp.data; const body = resp.data;
// 如果返回的 code 不是 0表示业务错误
if (body && body.code !== "0") { if (body && body.code !== "0") {
const errorMsg = body.msg || "请求失败"; const errorMsg = body.msg || "请求失败";
message.error(errorMsg); // 自动展示后端错误消息 message.error(errorMsg);
const err = new Error(errorMsg); const err = new Error(errorMsg);
(err as any).code = body.code; (err as any).code = body.code;
(err as any).msg = body.msg; (err as any).msg = body.msg;
@ -29,20 +125,30 @@ http.interceptors.response.use(
} }
return resp; return resp;
}, },
(error) => { async (error) => {
// 处理 HTTP 状态码错误 (4xx, 5xx) const originalRequest = error.config || {};
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!isAuthWhitelistRequest(originalRequest.url)
) {
originalRequest._retry = true;
const newToken = await refreshAccessToken();
if (newToken) {
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return http(originalRequest);
}
}
if (error.response && (error.response.status === 401 || error.response.status === 403)) { if (error.response && (error.response.status === 401 || error.response.status === 403)) {
localStorage.removeItem("accessToken"); clearAuthStorage();
localStorage.removeItem("refreshToken");
sessionStorage.removeItem("userProfile");
window.location.href = "/login?timeout=1"; window.location.href = "/login?timeout=1";
return Promise.reject(error); return Promise.reject(error);
} }
const body = error.response?.data; const body = error.response?.data;
const errorMsg = body?.msg || error.message || "网络异常"; const errorMsg = body?.msg || error.message || "网络异常";
// 防止重复弹出相同的提示(可选逻辑,根据需要调整)
message.error(errorMsg); message.error(errorMsg);
if (body && body.msg) { if (body && body.msg) {

View File

@ -1,6 +1,6 @@
import http from "./http"; import http from "./http";
import { import {
DeviceInfo, SysPermission, SysRole, SysUser, UserProfile, SysParamVO, SysParamQuery, PageResult, BotCredential, DeviceInfo, RoleDataScope, SysPermission, SysRole, SysUser, UserProfile, SysParamVO, SysParamQuery, PageResult,
PermissionNode PermissionNode
} from "../types"; } from "../types";
@ -29,7 +29,6 @@ export async function listUsers(params?: { tenantId?: number; orgId?: number })
return resp.data.data as SysUser[]; return resp.data.data as SysUser[];
} }
export async function createUser(payload: Partial<SysUser>) { export async function createUser(payload: Partial<SysUser>) {
const resp = await http.post("/sys/api/users", payload); const resp = await http.post("/sys/api/users", payload);
return resp.data.data as boolean; return resp.data.data as boolean;
@ -110,6 +109,16 @@ export async function updateMyPassword(payload: any) {
return resp.data.data as boolean; return resp.data.data as boolean;
} }
export async function getMyBotCredential() {
const resp = await http.get("/sys/api/users/bot-credential");
return resp.data.data as BotCredential;
}
export async function generateMyBotCredential() {
const resp = await http.post("/sys/api/users/bot-credential/generate");
return resp.data.data as BotCredential;
}
export async function createPermission(payload: Partial<SysPermission>) { export async function createPermission(payload: Partial<SysPermission>) {
const resp = await http.post("/sys/api/permissions", payload); const resp = await http.post("/sys/api/permissions", payload);
return resp.data.data as boolean; return resp.data.data as boolean;
@ -165,6 +174,16 @@ export async function saveRolePermissions(roleId: number, permIds: number[]) {
return resp.data.data as boolean; return resp.data.data as boolean;
} }
export async function getRoleDataScope(roleId: number) {
const resp = await http.get(`/sys/api/roles/${roleId}/data-scope`);
return resp.data.data as RoleDataScope;
}
export async function saveRoleDataScope(roleId: number, payload: RoleDataScope) {
const resp = await http.post(`/sys/api/roles/${roleId}/data-scope`, payload);
return resp.data.data as boolean;
}
export async function fetchUsersByRoleId(roleId: number) { export async function fetchUsersByRoleId(roleId: number) {
const resp = await http.get(`/sys/api/roles/${roleId}/users`); const resp = await http.get(`/sys/api/roles/${roleId}/users`);
return resp.data.data as SysUser[]; return resp.data.data as SysUser[];
@ -194,5 +213,3 @@ export * from "./dict";
export * from "./tenant"; export * from "./tenant";
export * from "./org"; export * from "./org";
export * from "./platform"; export * from "./platform";

View File

@ -62,6 +62,17 @@ export default function AppLayout() {
const [menus, setMenus] = useState<SysPermission[]>([]); const [menus, setMenus] = useState<SysPermission[]>([]);
const [availableTenants, setAvailableTenants] = useState<TenantInfo[]>([]); const [availableTenants, setAvailableTenants] = useState<TenantInfo[]>([]);
const [currentTenantId, setCurrentTenantId] = useState<number | null>(null); const [currentTenantId, setCurrentTenantId] = useState<number | null>(null);
const [currentUserLabel, setCurrentUserLabel] = useState<string>(() => {
try {
const profileStr = sessionStorage.getItem("userProfile");
if (profileStr) {
const profile = JSON.parse(profileStr) as { displayName?: string; username?: string };
return profile.displayName || profile.username || localStorage.getItem("username") || "";
}
} catch {
}
return localStorage.getItem("displayName") || localStorage.getItem("username") || "";
});
const [openKeys, setOpenKeys] = useState<string[]>([]); const [openKeys, setOpenKeys] = useState<string[]>([]);
const [platformConfig, setPlatformConfig] = useState<SysPlatformConfig | null>(() => { const [platformConfig, setPlatformConfig] = useState<SysPlatformConfig | null>(() => {
const configStr = sessionStorage.getItem("platformConfig"); const configStr = sessionStorage.getItem("platformConfig");
@ -86,6 +97,21 @@ export default function AppLayout() {
setCurrentTenantId(Number(activeTenantId)); setCurrentTenantId(Number(activeTenantId));
} }
try {
const profile = await getCurrentUser();
sessionStorage.setItem("userProfile", JSON.stringify(profile));
if (profile.username) {
localStorage.setItem("username", profile.username);
}
if (profile.displayName) {
localStorage.setItem("displayName", profile.displayName);
}
setCurrentUserLabel(profile.displayName || profile.username || "");
} catch {
const cached = localStorage.getItem("displayName") || localStorage.getItem("username") || "";
setCurrentUserLabel(cached);
}
const data = await listMyPermissions(); const data = await listMyPermissions();
await loadPermissions(); await loadPermissions();
@ -124,6 +150,13 @@ export default function AppLayout() {
const profile = await getCurrentUser(); const profile = await getCurrentUser();
sessionStorage.setItem("userProfile", JSON.stringify(profile)); sessionStorage.setItem("userProfile", JSON.stringify(profile));
if (profile.username) {
localStorage.setItem("username", profile.username);
}
if (profile.displayName) {
localStorage.setItem("displayName", profile.displayName);
}
setCurrentUserLabel(profile.displayName || profile.username || "");
message.success(t("common.success")); message.success(t("common.success"));
window.location.reload(); window.location.reload();
@ -283,7 +316,7 @@ export default function AppLayout() {
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight"> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Space style={{ cursor: "pointer", color: "var(--app-text-main)" }}> <Space style={{ cursor: "pointer", color: "var(--app-text-main)" }}>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "var(--app-primary-color)" }} /> <Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "var(--app-primary-color)" }} />
<span style={{ fontWeight: 500 }}>{localStorage.getItem("username") || t("layout.admin")}</span> <span style={{ fontWeight: 500 }}>{currentUserLabel || t("layout.admin")}</span>
</Space> </Space>
</Dropdown> </Dropdown>
</Space> </Space>

View File

@ -260,7 +260,18 @@
"saveChanges": "Save Changes", "saveChanges": "Save Changes",
"updatePassword": "Update Password", "updatePassword": "Update Password",
"passwordsDoNotMatch": "Passwords do not match.", "passwordsDoNotMatch": "Passwords do not match.",
"standardUser": "Standard User" "standardUser": "Standard User",
"botCredentialTab": "Bot Credential",
"botCredentialHint": "Use this credential pair to access /mcp with X-Bot-Id and X-Bot-Secret.",
"botCredentialHintDesc": "The secret is shown only after generation. Store it securely after copying.",
"botBindStatus": "Binding Status",
"botBound": "Bound",
"botUnbound": "Not Generated",
"botSecretHidden": "Hidden. Generate or reset to get a new secret.",
"botLastAccessTime": "Last Access Time",
"botLastAccessIp": "Last Access IP",
"generateBotCredential": "Generate Credential",
"regenerateBotCredential": "Regenerate Credential"
}, },
"rolesExt": { "rolesExt": {
"roleList": "Role List", "roleList": "Role List",

View File

@ -260,7 +260,18 @@
"saveChanges": "保存修改", "saveChanges": "保存修改",
"updatePassword": "更新密码", "updatePassword": "更新密码",
"passwordsDoNotMatch": "两次输入的密码不一致。", "passwordsDoNotMatch": "两次输入的密码不一致。",
"standardUser": "普通用户" "standardUser": "普通用户",
"botCredentialTab": "Bot 凭证",
"botCredentialHint": "使用这组凭证通过 X-Bot-Id 和 X-Bot-Secret 访问 /mcp。",
"botCredentialHintDesc": "Secret 只会在生成后显示一次,请复制后妥善保管。",
"botBindStatus": "绑定状态",
"botBound": "已绑定",
"botUnbound": "未生成",
"botSecretHidden": "已隐藏。如需查看新的 Secret请重新生成。",
"botLastAccessTime": "最近访问时间",
"botLastAccessIp": "最近访问 IP",
"generateBotCredential": "生成凭证",
"regenerateBotCredential": "重置凭证"
}, },
"rolesExt": { "rolesExt": {
"roleList": "角色列表", "roleList": "角色列表",

View File

@ -1,7 +1,6 @@
import { Avatar, Badge, Button, Card, Col, Drawer, Empty, Form, Input, List, Pagination, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Tooltip, Tree, Typography, Row } from "antd"; import { Avatar, Button, Card, Col, Drawer, Empty, Form, Input, List, Pagination, Radio, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Tooltip, Tree, Typography, Row } from "antd";
import type { DataNode } from "antd/es/tree"; import type { DataNode } from "antd/es/tree";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
ApartmentOutlined, ApartmentOutlined,
CheckCircleFilled, CheckCircleFilled,
@ -22,11 +21,14 @@ import {
createRole, createRole,
deleteRole, deleteRole,
fetchUsersByRoleId, fetchUsersByRoleId,
getRoleDataScope,
listOrgs,
listPermissions, listPermissions,
listRolePermissions, listRolePermissions,
listTenants, listTenants,
listUsers, listUsers,
pageRoles, pageRoles,
saveRoleDataScope,
saveRolePermissions, saveRolePermissions,
unbindUserFromRole, unbindUserFromRole,
updateRole updateRole
@ -34,14 +36,25 @@ import {
import { useDict } from "@/hooks/useDict"; import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission"; import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader"; import PageHeader from "@/components/shared/PageHeader";
import type { SysPermission, SysRole, SysTenant, SysUser } from "@/types"; import type { RoleDataScope, SysOrg, SysPermission, SysRole, SysTenant, SysUser } from "@/types";
import "./index.less"; import "./index.less";
const { Text, Title } = Typography; const { Text, Title } = Typography;
type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] }; type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
type OrgTreeNode = SysOrg & { key: number; children?: OrgTreeNode[] };
type RoleTabKey = "permissions" | "dataScope" | "users";
const DEFAULT_STATUS = 1; const DEFAULT_STATUS = 1;
const DEFAULT_ROLE_PAGE_SIZE = 10; const DEFAULT_ROLE_PAGE_SIZE = 10;
const BUTTON_SHORT_LABEL = "按钮";
const DATA_SCOPE_OPTIONS = [
{ label: "全部", value: "ALL" },
{ label: "个人", value: "SELF" },
{ label: "本部门", value: "DEPT" },
{ label: "本部门及下级部门", value: "DEPT_AND_CHILD" },
{ label: "自定义部门", value: "CUSTOM" }
] as const;
function normalizeNumber(value: unknown): number | undefined { function normalizeNumber(value: unknown): number | undefined {
if (typeof value === "number") { if (typeof value === "number") {
@ -56,6 +69,7 @@ function normalizeNumber(value: unknown): number | undefined {
} }
return undefined; return undefined;
} }
function buildPermissionTree(list: SysPermission[]): PermissionNode[] { function buildPermissionTree(list: SysPermission[]): PermissionNode[] {
const active = (list || []).filter((permission) => permission.status !== 0); const active = (list || []).filter((permission) => permission.status !== 0);
const map = new Map<number, PermissionNode>(); const map = new Map<number, PermissionNode>();
@ -85,7 +99,7 @@ function buildPermissionTree(list: SysPermission[]): PermissionNode[] {
return roots; return roots;
} }
function toTreeData(nodes: PermissionNode[], t: (key: string, options?: Record<string, unknown>) => string, buttonShortLabel: string): DataNode[] { function toPermissionTreeData(nodes: PermissionNode[], buttonShortLabel: string): DataNode[] {
return nodes.map((node) => ({ return nodes.map((node) => ({
key: node.permId, key: node.permId,
title: ( title: (
@ -94,14 +108,63 @@ function toTreeData(nodes: PermissionNode[], t: (key: string, options?: Record<s
{node.permType === "button" ? <Tag color="cyan" style={{ fontSize: 10 }}>{buttonShortLabel}</Tag> : null} {node.permType === "button" ? <Tag color="cyan" style={{ fontSize: 10 }}>{buttonShortLabel}</Tag> : null}
</Space> </Space>
), ),
children: node.children?.length ? toTreeData(node.children, t, buttonShortLabel) : undefined children: node.children?.length ? toPermissionTreeData(node.children, buttonShortLabel) : undefined
})); }));
} }
function buildOrgTree(list: SysOrg[]): OrgTreeNode[] {
const map = new Map<number, OrgTreeNode>();
const roots: OrgTreeNode[] = [];
list.forEach((item) => {
map.set(item.id, { ...item, key: item.id, children: [] });
});
map.forEach((node) => {
if (node.parentId && map.has(node.parentId)) {
map.get(node.parentId)!.children!.push(node);
} else {
roots.push(node);
}
});
const sortNodes = (nodes: OrgTreeNode[]) => {
nodes.sort((left, right) => (left.sortOrder || 0) - (right.sortOrder || 0));
nodes.forEach((node) => node.children && sortNodes(node.children));
};
sortNodes(roots);
return roots;
}
function toOrgTreeData(nodes: OrgTreeNode[]): DataNode[] {
return nodes.map((node) => ({
key: node.id,
title: node.orgName,
children: node.children?.length ? toOrgTreeData(node.children) : undefined
}));
}
function getDataScopeDescription(scopeType: string) {
switch (scopeType) {
case "ALL":
return "当前角色可访问当前租户全部数据。";
case "SELF":
return "当前角色仅可访问本人数据。";
case "DEPT":
return "当前角色可访问本人所在部门的数据。";
case "DEPT_AND_CHILD":
return "当前角色可访问本人所在部门及所有下级部门的数据。";
case "CUSTOM":
return "当前角色可访问选中部门的数据。";
default:
return "";
}
}
const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`; const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`;
export default function Roles() { export default function Roles() {
const { t } = useTranslation();
const { can } = usePermission(); const { can } = usePermission();
const { items: statusDict } = useDict("sys_common_status"); const { items: statusDict } = useDict("sys_common_status");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -119,21 +182,14 @@ export default function Roles() {
const [userSearchText, setUserSearchText] = useState(""); const [userSearchText, setUserSearchText] = useState("");
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [rolePage, setRolePage] = useState({ current: 1, size: DEFAULT_ROLE_PAGE_SIZE, total: 0 }); const [rolePage, setRolePage] = useState({ current: 1, size: DEFAULT_ROLE_PAGE_SIZE, total: 0 });
const handleSearch = () => {
setSearchText((value) => value.trim());
setRolePage((prev) => ({ ...prev, current: 1 }));
};
const handleResetSearch = () => {
setSearchText("");
setFilterTenantId(undefined);
setRolePage((prev) => ({ ...prev, current: 1 }));
};
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined); const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysRole | null>(null); const [editing, setEditing] = useState<SysRole | null>(null);
const [tenants, setTenants] = useState<SysTenant[]>([]); const [tenants, setTenants] = useState<SysTenant[]>([]);
const [activeTab, setActiveTab] = useState<RoleTabKey>("permissions");
const [dataScopeType, setDataScopeType] = useState("SELF");
const [scopeOrgIds, setScopeOrgIds] = useState<number[]>([]);
const [scopeOrgTree, setScopeOrgTree] = useState<DataNode[]>([]);
const [form] = Form.useForm(); const [form] = Form.useForm();
const isPlatformMode = useMemo(() => { const isPlatformMode = useMemo(() => {
@ -144,12 +200,15 @@ export default function Roles() {
}, []); }, []);
const activeTenantId = useMemo(() => normalizeNumber(localStorage.getItem("activeTenantId")) ?? 0, []); const activeTenantId = useMemo(() => normalizeNumber(localStorage.getItem("activeTenantId")) ?? 0, []);
const buttonShortLabel = useMemo(() => { const permissionTreeData = useMemo(() => toPermissionTreeData(buildPermissionTree(permissions), BUTTON_SHORT_LABEL), [permissions]);
const label = t("buttonShort"); const filteredModalUsers = useMemo(() => {
return !label || label === "buttonShort" ? "BTN" : label; const existingIds = new Set(roleUsers.map((user) => user.userId));
}, [t]); return allUsers.filter(
const permissionTreeData = useMemo(() => toTreeData(buildPermissionTree(permissions), t, buttonShortLabel), [buttonShortLabel, permissions, t]); (user) =>
!existingIds.has(user.userId) &&
(user.username.toLowerCase().includes(userSearchText.toLowerCase()) || user.displayName.toLowerCase().includes(userSearchText.toLowerCase()))
);
}, [allUsers, roleUsers, userSearchText]);
useEffect(() => { useEffect(() => {
if (!isPlatformMode) return; if (!isPlatformMode) return;
@ -160,23 +219,31 @@ export default function Roles() {
try { try {
const list = await listPermissions(); const list = await listPermissions();
setPermissions(list || []); setPermissions(list || []);
return list || [];
} catch { } catch {
setPermissions([]); setPermissions([]);
return [] as SysPermission[];
} }
}; };
const selectRole = async (role: SysRole) => { const selectRole = async (role: SysRole, permissionList: SysPermission[] = permissions) => {
setSelectedRole(role); setSelectedRole(role);
setLoadingUsers(true);
try { try {
const ids = await listRolePermissions(role.roleId); const [ids, users, dataScope, orgs] = await Promise.all([
listRolePermissions(role.roleId),
fetchUsersByRoleId(role.roleId),
getRoleDataScope(role.roleId),
listOrgs(role.tenantId)
]);
const normalized = (ids || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id)); const normalized = (ids || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id));
const leafIds = normalized.filter((id) => !permissions.some((permission) => permission.parentId === id)); const leafIds = normalized.filter((id) => !permissionList.some((permission) => permission.parentId === id));
setSelectedPermIds(leafIds); setSelectedPermIds(leafIds);
setHalfCheckedIds([]); setHalfCheckedIds([]);
setLoadingUsers(true);
const users = await fetchUsersByRoleId(role.roleId);
setRoleUsers(users || []); setRoleUsers(users || []);
setDataScopeType(dataScope?.scopeType || role.dataScopeType || "SELF");
setScopeOrgIds((dataScope?.orgIds || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id)));
setScopeOrgTree(toOrgTreeData(buildOrgTree(orgs || [])));
} finally { } finally {
setLoadingUsers(false); setLoadingUsers(false);
} }
@ -185,6 +252,7 @@ export default function Roles() {
const loadRoles = async (page = rolePage.current, size = rolePage.size) => { const loadRoles = async (page = rolePage.current, size = rolePage.size) => {
setLoading(true); setLoading(true);
try { try {
const permissionList = await loadPermissions();
const response = await pageRoles({ const response = await pageRoles({
current: page, current: page,
size, size,
@ -199,24 +267,26 @@ export default function Roles() {
setRoleUsers([]); setRoleUsers([]);
setSelectedPermIds([]); setSelectedPermIds([]);
setHalfCheckedIds([]); setHalfCheckedIds([]);
setDataScopeType("SELF");
setScopeOrgIds([]);
setScopeOrgTree([]);
} else if (!selectedRole) { } else if (!selectedRole) {
await selectRole(roles[0]); await selectRole(roles[0], permissionList);
} else { } else {
const updated = roles.find((role) => role.roleId === selectedRole.roleId); const updated = roles.find((role) => role.roleId === selectedRole.roleId);
if (updated) { if (updated) {
setSelectedRole(updated); await selectRole(updated, permissionList);
} else { } else {
await selectRole(roles[0]); await selectRole(roles[0], permissionList);
} }
} }
await loadPermissions();
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
loadRoles(rolePage.current, rolePage.size); void loadRoles(rolePage.current, rolePage.size);
}, [filterTenantId, rolePage.current, rolePage.size, searchText]); }, [filterTenantId, rolePage.current, rolePage.size, searchText]);
const loadAllUsers = async () => { const loadAllUsers = async () => {
@ -229,7 +299,7 @@ export default function Roles() {
}; };
const openUserModal = () => { const openUserModal = () => {
loadAllUsers(); void loadAllUsers();
setSelectedUserKeys([]); setSelectedUserKeys([]);
setUserModalOpen(true); setUserModalOpen(true);
}; };
@ -237,33 +307,22 @@ export default function Roles() {
const handleAddUsers = async () => { const handleAddUsers = async () => {
if (!selectedRole || selectedUserKeys.length === 0) return; if (!selectedRole || selectedUserKeys.length === 0) return;
await bindUsersToRole(selectedRole.roleId, selectedUserKeys); await bindUsersToRole(selectedRole.roleId, selectedUserKeys);
message.success(t("common.success")); message.success("操作成功");
setUserModalOpen(false); setUserModalOpen(false);
selectRole(selectedRole); await selectRole(selectedRole);
}; };
const handleUnbindUser = async (userId: number) => { const handleUnbindUser = async (userId: number) => {
if (!selectedRole) return; if (!selectedRole) return;
if (selectedRole.roleCode === "TENANT_ADMIN" && roleUsers.length <= 1) { if (selectedRole.roleCode === "TENANT_ADMIN" && roleUsers.length <= 1) {
message.warning(t("rolesExt.tenantAdminWarning")); message.warning("租户管理员角色至少需要保留一个绑定用户");
return; return;
} }
await unbindUserFromRole(selectedRole.roleId, userId); await unbindUserFromRole(selectedRole.roleId, userId);
message.success(t("common.success")); message.success("操作成功");
selectRole(selectedRole); await selectRole(selectedRole);
}; };
const filteredModalUsers = useMemo(() => {
const existingIds = new Set(roleUsers.map((user) => user.userId));
return allUsers.filter(
(user) =>
!existingIds.has(user.userId) &&
(user.username.toLowerCase().includes(userSearchText.toLowerCase()) || user.displayName.toLowerCase().includes(userSearchText.toLowerCase()))
);
}, [allUsers, roleUsers, userSearchText]);
const openCreate = () => { const openCreate = () => {
setEditing(null); setEditing(null);
form.resetFields(); form.resetFields();
@ -281,9 +340,9 @@ export default function Roles() {
const handleRemove = async (event: React.MouseEvent, id: number) => { const handleRemove = async (event: React.MouseEvent, id: number) => {
event.stopPropagation(); event.stopPropagation();
await deleteRole(id); await deleteRole(id);
message.success(t("common.success")); message.success("操作成功");
if (selectedRole?.roleId === id) setSelectedRole(null); if (selectedRole?.roleId === id) setSelectedRole(null);
loadRoles(rolePage.current, rolePage.size); await loadRoles(rolePage.current, rolePage.size);
}; };
const submitBasic = async () => { const submitBasic = async () => {
@ -295,16 +354,17 @@ export default function Roles() {
roleName: values.roleName, roleName: values.roleName,
remark: values.remark, remark: values.remark,
status: values.status ?? DEFAULT_STATUS, status: values.status ?? DEFAULT_STATUS,
tenantId: values.tenantId tenantId: values.tenantId,
dataScopeType: editing?.dataScopeType || "SELF"
}; };
if (editing) { if (editing) {
await updateRole(editing.roleId, payload); await updateRole(editing.roleId, payload);
} else { } else {
await createRole(payload); await createRole(payload);
} }
message.success(t("common.success")); message.success("操作成功");
setDrawerOpen(false); setDrawerOpen(false);
loadRoles(rolePage.current, rolePage.size); await loadRoles(rolePage.current, rolePage.size);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -319,33 +379,63 @@ export default function Roles() {
setSaving(true); setSaving(true);
try { try {
await saveRolePermissions(selectedRole.roleId, Array.from(new Set([...selectedPermIds, ...halfCheckedIds]))); await saveRolePermissions(selectedRole.roleId, Array.from(new Set([...selectedPermIds, ...halfCheckedIds])));
message.success(t("common.success")); message.success("操作成功");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const saveDataScope = async () => {
if (!selectedRole) return;
if (dataScopeType === "CUSTOM" && scopeOrgIds.length === 0) {
message.warning("请选择至少一个部门");
return;
}
setSaving(true);
try {
const payload: RoleDataScope = {
roleId: selectedRole.roleId,
scopeType: dataScopeType,
orgIds: dataScopeType === "CUSTOM" ? scopeOrgIds : []
};
await saveRoleDataScope(selectedRole.roleId, payload);
message.success("操作成功");
setSelectedRole((prev) => (prev ? { ...prev, dataScopeType } : prev));
setData((prev) => prev.map((item) => item.roleId === selectedRole.roleId ? { ...item, dataScopeType } : item));
} finally {
setSaving(false);
}
};
const handlePrimarySave = () => {
if (activeTab === "permissions") {
void savePermissions();
return;
}
if (activeTab === "dataScope") {
void saveDataScope();
}
};
const saveDisabled = !selectedRole || activeTab === "users" || (activeTab === "permissions" && !can("sys:role:permission:save")) || (activeTab === "dataScope" && !can("sys:role:update"));
const saveLabel = activeTab === "dataScope" ? "保存数据权限" : "保存";
return ( return (
<div className="app-page roles-page-v2"> <div className="app-page roles-page-v2">
<PageHeader <PageHeader title="角色管理" subtitle="维护角色基础信息、功能权限、数据权限与成员绑定" />
title={t("roles.title")}
subtitle={t("roles.subtitle")}
/>
<div className="app-page__page-actions"> <div className="app-page__page-actions">
{can("sys:role:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{t("common.create")}</Button>} {can("sys:role:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{"新增角色"}</Button>}
</div> </div>
<div className="roles-layout"> <div className="roles-layout">
<Row gutter={24} className="roles-layout__row"> <Row gutter={24} className="roles-layout__row">
<Col span={7} className="roles-layout__side"> <Col span={7} className="roles-layout__side">
<Card title={<Space><ApartmentOutlined /><span>{t("rolesExt.roleList")}</span> <Card title={<Space><ApartmentOutlined /><span>{"角色列表"}</span></Space>} bordered={false} className="app-page__panel-card roles-side-card">
{/*<Badge count={rolePage.total} overflowCount={999} className="roles-count-badge" />*/}
</Space>} bordered={false} className="app-page__panel-card roles-side-card">
<div className="role-search-panel"> <div className="role-search-panel">
{isPlatformMode && ( {isPlatformMode && (
<Select <Select
placeholder={t("rolesExt.filterTenant")} placeholder="按租户筛选"
style={{ width: "100%" }} style={{ width: "100%" }}
allowClear allowClear
suffixIcon={<FilterOutlined />} suffixIcon={<FilterOutlined />}
@ -355,8 +445,8 @@ export default function Roles() {
/> />
)} )}
<div className="role-search-bar"> <div className="role-search-bar">
<Input placeholder={t("roles.searchPlaceholder")} prefix={<SearchOutlined style={{ color: "#94a3b8" }} />} value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear /> <Input placeholder="输入角色名称或编码搜索" prefix={<SearchOutlined style={{ color: "#94a3b8" }} />} value={searchText} onChange={(event) => setSearchText(event.target.value)} allowClear />
<Button type="default" onClick={handleResetSearch}>{t("common.reset")}</Button> <Button type="default" onClick={() => { setSearchText(""); setFilterTenantId(undefined); setRolePage((prev) => ({ ...prev, current: 1 })); }}>{"重置"}</Button>
</div> </div>
</div> </div>
@ -365,17 +455,17 @@ export default function Roles() {
loading={loading} loading={loading}
dataSource={data} dataSource={data}
pagination={false} pagination={false}
locale={{ emptyText: <Empty description={t("rolesExt.noRolesFound")} image={Empty.PRESENTED_IMAGE_SIMPLE} /> }} locale={{ emptyText: <Empty description="暂无角色数据" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
renderItem={(item) => ( renderItem={(item) => (
<div key={item.roleId} className={`role-item-card-v3 ${selectedRole?.roleId === item.roleId ? "active" : ""}`} onClick={() => selectRole(item)}> <div key={item.roleId} className={`role-item-card-v3 ${selectedRole?.roleId === item.roleId ? "active" : ""}`} onClick={() => void selectRole(item)}>
<div className="role-item-symbol" aria-hidden="true"> <div className="role-item-symbol" aria-hidden="true">
<SafetyCertificateOutlined /> <SafetyCertificateOutlined />
</div> </div>
<div className="role-item-main"> <div className="role-item-main">
<div className="role-item-name-row"> <div className="role-item-name-row">
<Text strong className="role-name">{item.roleName}</Text> <Text strong className="role-name">{item.roleName}</Text>
{isPlatformMode && <Tag color="blue" style={{ fontSize: 10, scale: "0.8", margin: "0 0 0 4px", borderRadius: "10px" }}>{item.tenantId === 0 ? t("rolesExt.systemTenant") : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `${t("rolesExt.tenantLabel")}:${item.tenantId}`}</Tag>} {isPlatformMode && <Tag color="blue" style={{ fontSize: 10, scale: "0.8", margin: "0 0 0 4px", borderRadius: "10px" }}>{item.tenantId === 0 ? "平台租户" : tenants.find((tenant) => tenant.id === item.tenantId)?.tenantName || `租户:${item.tenantId}`}</Tag>}
{item.status === 0 && <Tag color="error" style={{ fontSize: 10, scale: "0.8", margin: 0 }}>{t("rolesExt.disabled")}</Tag>} {item.status === 0 && <Tag color="error" style={{ fontSize: 10, scale: "0.8", margin: 0 }}>{"停用"}</Tag>}
</div> </div>
<Text type="secondary" className="role-code">{item.roleCode}</Text> <Text type="secondary" className="role-code">{item.roleCode}</Text>
</div> </div>
@ -386,11 +476,11 @@ export default function Roles() {
) : null} ) : null}
<div className="role-item-actions"> <div className="role-item-actions">
<Space size={4}> <Space size={4}>
<Tooltip title={t("common.edit")}> <Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />} onClick={(event) => openEditBasic(event, item)} /> <Button type="text" size="small" icon={<EditOutlined />} onClick={(event) => openEditBasic(event, item)} />
</Tooltip> </Tooltip>
{item.roleCode !== "ADMIN" && ( {item.roleCode !== "ADMIN" && (
<Popconfirm title={t("rolesExt.deleteRole")} okText={t("common.confirm")} cancelText={t("common.cancel")} onConfirm={(event) => handleRemove(event!, item.roleId)}> <Popconfirm title="确定删除该角色吗?" okText="确定" cancelText="取消" onConfirm={(event) => void handleRemove(event!, item.roleId)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(event) => event.stopPropagation()} /> <Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(event) => event.stopPropagation()} />
</Popconfirm> </Popconfirm>
)} )}
@ -420,10 +510,10 @@ export default function Roles() {
className="app-page__panel-card roles-detail-card" className="app-page__panel-card roles-detail-card"
bordered={false} bordered={false}
title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>} title={<div className="role-detail-header"><div className="role-detail-icon"><SafetyCertificateOutlined /></div><div className="role-detail-heading"><div className="role-detail-title">{selectedRole.roleName}</div><Text type="secondary" className="role-detail-code">{selectedRole.roleCode}</Text></div></div>}
extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={savePermissions} disabled={!can("sys:role:permission:save") || (selectedRole.roleCode === "TENANT_ADMIN" && !isPlatformMode)} style={{ borderRadius: "6px" }}>{t("common.save")}</Button>} extra={<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handlePrimarySave} disabled={saveDisabled} style={{ borderRadius: "6px" }}>{saveLabel}</Button>}
> >
<Tabs defaultActiveKey="permissions" className="role-detail-tabs"> <Tabs activeKey={activeTab} onChange={(key) => setActiveTab(key as RoleTabKey)} className="role-detail-tabs">
<Tabs.TabPane tab={<Space><KeyOutlined />{t("roles.funcPerms")}</Space>} key="permissions"> <Tabs.TabPane tab={<Space><KeyOutlined />{"功能权限"}</Space>} key="permissions">
<div className="role-detail-pane"> <div className="role-detail-pane">
<div className="permission-tree-wrapper"> <div className="permission-tree-wrapper">
<Tree <Tree
@ -443,11 +533,40 @@ export default function Roles() {
</div> </div>
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={<Space><TeamOutlined />{t("rolesExt.membersTab")} ({roleUsers.length})</Space>} key="users"> <Tabs.TabPane tab={<Space><ApartmentOutlined />{"数据权限"}</Space>} key="dataScope">
<div className="role-detail-pane">
<div style={{ marginBottom: 16 }}>
<Radio.Group value={dataScopeType} onChange={(event) => setDataScopeType(event.target.value)} optionType="button" buttonStyle="solid">
{DATA_SCOPE_OPTIONS.map((item) => (
<Radio.Button key={item.value} value={item.value}>{item.label}</Radio.Button>
))}
</Radio.Group>
</div>
<div style={{ marginBottom: 16, color: "#64748b" }}>{getDataScopeDescription(dataScopeType)}</div>
{dataScopeType === "CUSTOM" ? (
<div className="permission-tree-wrapper">
<Tree
checkable
selectable={false}
treeData={scopeOrgTree}
checkedKeys={scopeOrgIds}
onCheck={(keys) => {
const checked = Array.isArray(keys) ? keys : keys.checked;
setScopeOrgIds(checked.map((key) => Number(key)));
}}
defaultExpandAll
/>
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前范围不需要选择部门" />
)}
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={<Space><TeamOutlined />{`成员管理 (${roleUsers.length})`}</Space>} key="users">
<div className="role-detail-pane"> <div className="role-detail-pane">
<div className="role-members-toolbar"> <div className="role-members-toolbar">
<Title level={5} style={{ margin: 0 }}>{t("rolesExt.assignedUsers")}</Title> <Title level={5} style={{ margin: 0 }}>{"已绑定用户"}</Title>
<Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{t("rolesExt.bindUser")}</Button> <Button type="primary" ghost icon={<UserAddOutlined />} onClick={openUserModal} disabled={!can("sys:role:update")}>{"绑定用户"}</Button>
</div> </div>
<Table <Table
rowKey="userId" rowKey="userId"
@ -457,8 +576,8 @@ export default function Roles() {
pagination={{ pageSize: 10, size: "small" }} pagination={{ pageSize: 10, size: "small" }}
columns={[ columns={[
{ {
title: t("users.userInfo"), title: "用户信息",
render: (_: any, user: SysUser) => ( render: (_: unknown, user: SysUser) => (
<Space> <Space>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} /> <Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: "#f0f2f5", color: "#8c8c8c" }} />
<div> <div>
@ -468,14 +587,14 @@ export default function Roles() {
</Space> </Space>
) )
}, },
{ title: t("rolesExt.phone"), dataIndex: "phone", className: "tabular-nums" }, { title: "手机号", dataIndex: "phone", className: "tabular-nums" },
{ title: t("common.status"), dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? t("logsExt.success") : t("rolesExt.disabled")}</Tag> }, { title: "状态", dataIndex: "status", width: 80, render: (status: number) => <Tag color={status === 1 ? "green" : "red"}>{status === 1 ? "启用" : "停用"}</Tag> },
{ {
title: t("common.action"), title: "操作",
key: "action", key: "action",
width: 80, width: 80,
render: (_: any, user: SysUser) => ( render: (_: unknown, user: SysUser) => (
<Popconfirm title={t("rolesExt.removeBinding")} okText={t("common.confirm")} cancelText={t("common.cancel")} onConfirm={() => handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}> <Popconfirm title="确定解除该用户绑定吗?" okText="确定" cancelText="取消" onConfirm={() => void handleUnbindUser(user.userId)} disabled={!can("sys:role:update")}>
<Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} /> <Button type="text" danger size="small" icon={<DeleteOutlined />} disabled={!can("sys:role:update")} />
</Popconfirm> </Popconfirm>
) )
@ -487,51 +606,38 @@ export default function Roles() {
</Tabs> </Tabs>
</Card> </Card>
) : ( ) : (
<div className="app-page__empty-state"><Empty description={t("roles.selectRole")} /></div> <div className="app-page__empty-state"><Empty description="请选择左侧角色查看详情" /></div>
)} )}
</Col> </Col>
</Row> </Row>
</div> </div>
<Modal title={t("rolesExt.bindUsersToRole")} open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={handleAddUsers} okText={t("common.confirm")} cancelText={t("common.cancel")} width={650} destroyOnClose> <Modal title="绑定用户到角色" open={userModalOpen} onCancel={() => setUserModalOpen(false)} onOk={() => void handleAddUsers()} okText="确定" cancelText="取消" width={650} destroyOnClose>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Input placeholder={t("rolesExt.searchUser")} prefix={<SearchOutlined />} value={userSearchText} onChange={(event) => setUserSearchText(event.target.value)} allowClear /> <Input placeholder="搜索用户名或显示名称" prefix={<SearchOutlined />} value={userSearchText} onChange={(event) => setUserSearchText(event.target.value)} allowClear />
</div> </div>
<Table rowKey="userId" size="small" dataSource={filteredModalUsers} pagination={{ pageSize: 6 }} rowSelection={{ selectedRowKeys: selectedUserKeys, onChange: (keys) => setSelectedUserKeys(keys as number[]) }} columns={[{ title: t("rolesExt.displayName"), dataIndex: "displayName" }, { title: t("users.username"), dataIndex: "username" }, { title: t("rolesExt.phone"), dataIndex: "phone" }]} /> <Table rowKey="userId" size="small" dataSource={filteredModalUsers} pagination={{ pageSize: 6 }} rowSelection={{ selectedRowKeys: selectedUserKeys, onChange: (keys) => setSelectedUserKeys(keys as number[]) }} columns={[{ title: "显示名称", dataIndex: "displayName" }, { title: "用户名", dataIndex: "username" }, { title: "手机号", dataIndex: "phone" }]} />
</Modal> </Modal>
<Drawer title={editing ? t("roles.drawerTitleEdit") : t("roles.drawerTitleCreate")} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{t("common.cancel")}</Button><Button type="primary" loading={saving} onClick={submitBasic}>{t("common.save")}</Button></div>}> <Drawer title={editing ? "编辑角色" : "新增角色"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={420} destroyOnClose footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}>{"取消"}</Button><Button type="primary" loading={saving} onClick={() => void submitBasic()}>{"保存"}</Button></div>}>
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item label={t("rolesExt.tenantLabel")} name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}> <Form.Item label="租户" name="tenantId" rules={[{ required: true }]} hidden={!isPlatformMode}>
<Select options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} disabled={!!editing} /> <Select options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))} disabled={!!editing} />
</Form.Item> </Form.Item>
<Form.Item label={t("roles.roleName")} name="roleName" rules={[{ required: true }]}> <Form.Item label="角色名称" name="roleName" rules={[{ required: true }]}>
<Input placeholder={t("rolesExt.enterRoleName")} /> <Input placeholder="请输入角色名称" />
</Form.Item> </Form.Item>
<Form.Item label={t("roles.roleCode")} name="roleCode" rules={[{ required: true }]}> <Form.Item label="角色编码" name="roleCode" rules={[{ required: true }]}>
<Input placeholder={t("rolesExt.roleCodePlaceholder")} disabled={!!editing} /> <Input placeholder="请输入角色编码" disabled={!!editing} />
</Form.Item> </Form.Item>
<Form.Item label={t("common.status")} name="status" initialValue={1}> <Form.Item label="状态" name="status" initialValue={1}>
<Select options={statusDict.map((item) => ({ label: item.itemLabel, value: Number(item.itemValue) }))} /> <Select options={statusDict.map((item) => ({ label: item.itemLabel, value: Number(item.itemValue) }))} />
</Form.Item> </Form.Item>
<Form.Item label={t("common.remark")} name="remark"> <Form.Item label="备注" name="remark">
<Input.TextArea rows={4} placeholder={t("rolesExt.roleScopePlaceholder")} /> <Input.TextArea rows={4} placeholder="请输入角色说明或适用范围" />
</Form.Item> </Form.Item>
</Form> </Form>
</Drawer> </Drawer>
</div> </div>
); );
} }

View File

@ -248,7 +248,7 @@ export default function Users() {
} }
if (values.password) { if (values.password) {
userPayload.passwordHash = values.password; userPayload.password = values.password;
} }
let userId = editing?.userId; let userId = editing?.userId;

View File

@ -1,18 +1,21 @@
import { Avatar, Button, Card, Col, Form, Input, Row, Tabs, Tag, Typography, message } from "antd"; import { Alert, Avatar, Button, Card, Col, Descriptions, Form, Input, Row, Space, Tabs, Tag, Typography, message } from "antd";
import { LockOutlined, SaveOutlined, SolutionOutlined, UserOutlined } from "@ant-design/icons"; import { KeyOutlined, LockOutlined, ReloadOutlined, SaveOutlined, SolutionOutlined, UserOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getCurrentUser, updateMyPassword, updateMyProfile } from "@/api"; import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile } from "@/api";
import PageHeader from "@/components/shared/PageHeader"; import PageHeader from "@/components/shared/PageHeader";
import type { UserProfile } from "@/types"; import type { BotCredential, UserProfile } from "@/types";
const { Title, Text } = Typography; const { Paragraph, Title, Text } = Typography;
export default function Profile() { export default function Profile() {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [credentialLoading, setCredentialLoading] = useState(false);
const [credentialSaving, setCredentialSaving] = useState(false);
const [user, setUser] = useState<UserProfile | null>(null); const [user, setUser] = useState<UserProfile | null>(null);
const [credential, setCredential] = useState<BotCredential | null>(null);
const [profileForm] = Form.useForm(); const [profileForm] = Form.useForm();
const [pwdForm] = Form.useForm(); const [pwdForm] = Form.useForm();
@ -27,8 +30,19 @@ export default function Profile() {
} }
}; };
const loadCredential = async () => {
setCredentialLoading(true);
try {
const data = await getMyBotCredential();
setCredential(data);
} finally {
setCredentialLoading(false);
}
};
useEffect(() => { useEffect(() => {
loadUser(); loadUser();
loadCredential();
}, []); }, []);
const handleUpdateProfile = async () => { const handleUpdateProfile = async () => {
@ -55,6 +69,19 @@ export default function Profile() {
} }
}; };
const handleGenerateCredential = async () => {
try {
setCredentialSaving(true);
const data = await generateMyBotCredential();
setCredential(data);
message.success(t("common.success"));
} finally {
setCredentialSaving(false);
}
};
const renderValue = (value?: string) => value || "-";
return ( return (
<div className="app-page app-page--contained" style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}> <div className="app-page app-page--contained" style={{ maxWidth: 1024, width: "100%", margin: "0 auto" }}>
<PageHeader title={t("profile.title")} subtitle={t("profile.subtitle")} /> <PageHeader title={t("profile.title")} subtitle={t("profile.subtitle")} />
@ -134,6 +161,77 @@ export default function Profile() {
</div> </div>
</Form> </Form>
) )
},
{
key: "bot-credential",
label: <span><KeyOutlined /> {t("profile.botCredentialTab")}</span>,
children: (
<div style={{ marginTop: 16 }}>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Alert
type="info"
showIcon
message={t("profile.botCredentialHint")}
description={t("profile.botCredentialHintDesc")}
/>
<Descriptions
bordered
size="middle"
column={1} items={[
{
key: "bind-status",
label: t("profile.botBindStatus"),
children: credential?.bound
? <Tag color="success">{t("profile.botBound")}</Tag>
: <Tag>{t("profile.botUnbound")}</Tag>
},
{
key: "bot-id",
label: "X-Bot-Id",
children: credential?.botId ? (
<Paragraph copyable={{ text: credential.botId }} style={{ marginBottom: 0 }}>
{credential.botId}
</Paragraph>
) : "-"
},
{
key: "bot-secret",
label: "X-Bot-Secret",
children: credential?.botSecret ? (
<Paragraph copyable={{ text: credential.botSecret }} style={{ marginBottom: 0 }}>
{credential.botSecret}
</Paragraph>
) : t("profile.botSecretHidden")
},
{
key: "last-access-time",
label: t("profile.botLastAccessTime"),
children: renderValue(credential?.lastAccessTime)
},
{
key: "last-access-ip",
label: t("profile.botLastAccessIp"),
children: renderValue(credential?.lastAccessIp)
}
]}
/>
<div className="app-page__page-actions" style={{ margin: "8px 0 0" }}>
<Button
type="primary"
icon={credential?.bound ? <ReloadOutlined /> : <KeyOutlined />}
loading={credentialSaving}
onClick={handleGenerateCredential}
>
{credential?.bound
? t("profile.regenerateBotCredential")
: t("profile.generateBotCredential")}
</Button>
</div>
</Space>
</div>
)
} }
]} ]}
/> />
@ -143,3 +241,5 @@ export default function Profile() {
</div> </div>
); );
} }

View File

@ -57,7 +57,7 @@ export default function SysParams() {
const openCreate = () => { const openCreate = () => {
setEditing(null); setEditing(null);
form.resetFields(); form.resetFields();
form.setFieldsValue({ isSystem: false, status: 1 }); form.setFieldsValue({ isSystem: 0, status: 1 });
setDrawerOpen(true); setDrawerOpen(true);
}; };

View File

@ -12,6 +12,7 @@ export interface SysUser extends BaseEntity {
displayName: string; displayName: string;
email?: string; email?: string;
phone?: string; phone?: string;
password?: string;
passwordHash?: string; passwordHash?: string;
tenantId: number; tenantId: number;
orgId?: number; orgId?: number;
@ -33,12 +34,28 @@ export interface UserProfile {
pwdResetRequired?: number; pwdResetRequired?: number;
} }
export interface BotCredential {
bound: boolean;
botId?: string;
botSecret?: string;
status?: string;
expireTime?: string;
lastAccessTime?: string;
lastAccessIp?: string;
}
export interface SysRole extends BaseEntity { export interface SysRole extends BaseEntity {
roleId: number; roleId: number;
roleCode: string; roleCode: string;
roleName: string; roleName: string;
remark?: string; remark?: string;
dataScopeType?: string;
}
export interface RoleDataScope {
roleId?: number;
scopeType: string;
orgIds: number[];
} }
export interface SysPermission extends BaseEntity { export interface SysPermission extends BaseEntity {
@ -167,4 +184,3 @@ export interface MenuRoute {
element: ReactNode; element: ReactNode;
perm?: string; perm?: string;
} }

8
node_modules/.vite/deps/_metadata.json generated vendored 100644
View File

@ -0,0 +1,8 @@
{
"hash": "87263cf7",
"configHash": "1878c139",
"lockfileHash": "e3b0c442",
"browserHash": "4129e467",
"optimized": {},
"chunks": {}
}

3
node_modules/.vite/deps/package.json generated vendored 100644
View File

@ -0,0 +1,3 @@
{
"type": "module"
}